Architecture
Technical organization and architectural principles of Pumpkin Hub.
Overview
Pumpkin Hub follows a classic client-server architecture with a clean
separation between the frontend (Next.js) and backend (Rust/Axum API). Services communicate
via REST JSON calls under the /api/v1 prefix.
┌──────────────────────────────────────────────────────────┐
│ CLIENT (Browser) │
└──────────────┬───────────────────────────────────────────┘
│ HTTPS
┌──────────────▼───────────────────────────────────────────┐
│ FRONTEND — Next.js 16 (SSR/SSG) │
│ Port 3000 · React 19 · Tailwind 4 │
└──────────────┬───────────────────────────────────────────┘
│ REST JSON /api/v1/*
┌──────────────▼───────────────────────────────────────────┐
│ BACKEND — Axum 0.8 (Rust) │
│ Port 8080 · Tokio · Tower Middlewares │
├──────────────┬──────────────────────┬────────────────────┤
│ │ │ │
│ ┌───────────▼──────────┐ ┌───────▼────────────┐ │
│ │ PostgreSQL 16 │ │ Meilisearch v1.7 │ │
│ │ Persistent data │ │ Full-text search │ │
│ └──────────────────────┘ └────────────────────┘ │
└──────────────────────────────────────────────────────────┘
Project Structure
pumpkin-hub/
├── api/ # Rust backend (Axum)
│ ├── Cargo.toml # Dependencies and metadata
│ ├── Dockerfile # Production multi-stage build (Alpine, static binary)
│ ├── Dockerfile.dev # Development container
│ ├── migrations/ # SQLx SQL migrations (up/down)
│ └── src/
│ ├── main.rs # Entry point, DB init, graceful shutdown
│ ├── lib.rs # build_app() and middleware stack
│ ├── config.rs # Configuration from ENV
│ ├── db.rs # PostgreSQL connection pool & migrations
│ ├── email.rs # Async SMTP email service (lettre)
│ ├── error.rs # Centralized error type (AppError)
│ ├── rate_limit.rs # Per-IP rate limiting (tower-governor)
│ ├── state.rs # Shared state (AppState + PgPool + ObjectStorage)
│ ├── auth/ # Multi-provider authentication
│ │ ├── mod.rs # Auth module exports
│ │ ├── github.rs # GitHub API user fetch
│ │ ├── google.rs # Google OpenID user fetch
│ │ ├── discord.rs # Discord API user fetch
│ │ ├── password.rs # Argon2id password hashing & verification
│ │ ├── jwt.rs # JWT creation & validation
│ │ └── middleware.rs # AuthUser extractor middleware
│ ├── models/ # Data models (SQLx FromRow + Serde)
│ │ ├── user.rs # User (multi-provider, optional password)
│ │ ├── auth_provider.rs # OAuth provider links per user
│ │ ├── plugin.rs # Plugin (registry entry)
│ │ ├── version.rs # Version (semver release)
│ │ ├── binary.rs # Binary (compiled artifact per version+platform)
│ │ ├── category.rs # Category (is_active, display_order)
│ │ ├── plugin_category.rs # Junction table
│ │ ├── plugin_dependency.rs # Inter-plugin dependency
│ │ ├── plugin_media.rs # Media gallery item (image/video)
│ │ ├── plugin_changelog.rs # Plugin changelog (UPSERT, source flag)
│ │ ├── review.rs # Plugin review (star rating + text)
│ │ └── review_report.rs # Abuse report on a review
│ ├── search/ # Meilisearch integration
│ │ └── mod.rs # Plugin indexing, search, suggestions, Pumpkin versions
│ ├── storage/ # Object storage abstraction
│ │ └── mod.rs # S3/MinIO client (upload, presigned URLs, delete)
│ └── routes/
│ ├── mod.rs # Sub-router assembly
│ ├── health.rs # GET /api/v1/health (+ DB check)
│ ├── auth.rs # Auth routes (register, login, OAuth, email verification, password reset)
│ ├── users.rs # Public author profiles
│ ├── categories/ # Category listing module
│ │ ├── mod.rs # Route declarations
│ │ ├── dto.rs # CategoryResponse DTO
│ │ └── handlers.rs # list_categories handler
│ ├── plugins/ # Plugin CRUD module
│ │ ├── mod.rs # Route declarations
│ │ ├── dto.rs # Request/response DTOs & validation
│ │ └── handlers.rs # Handler functions & helpers
│ ├── dependencies/# Dependency management module
│ │ ├── mod.rs # Route declarations & graph resolution
│ │ ├── dto.rs # Dependency DTOs
│ │ └── handlers.rs # CRUD handlers & conflict detection
│ ├── search/ # Search endpoints
│ │ ├── mod.rs # Route declarations
│ │ └── handlers.rs # Search & suggest handlers
│ ├── dashboard/ # Author analytics module
│ │ ├── mod.rs # Route declarations
│ │ ├── dto.rs # Stats & chart DTOs
│ │ └── handlers.rs # KPIs, download charts, per-plugin stats
│ ├── api_keys/ # API key management module
│ │ ├── mod.rs # Route declarations
│ │ ├── dto.rs # Key request/response DTOs
│ │ └── handlers.rs # Create, list, revoke API keys
│ ├── notifications/# Notification center module
│ │ ├── mod.rs # Route declarations
│ │ ├── dto.rs # Notification DTOs
│ │ └── handlers.rs # List, unread count, mark read, create helpers
│ ├── reviews/ # Plugin review system module
│ │ ├── mod.rs # Route declarations
│ │ ├── dto.rs # Review request/response DTOs
│ │ └── handlers.rs # Create, update, delete, hide, report, admin moderation
│ ├── media/ # Plugin media gallery module
│ │ ├── mod.rs # Route declarations
│ │ ├── dto.rs # MediaItem response DTO
│ │ └── handlers.rs # Upload, update caption, delete, reorder
│ ├── changelogs/ # Plugin changelog module
│ │ ├── mod.rs # Route declarations
│ │ ├── dto.rs # ChangelogResponse DTO
│ │ └── handlers.rs # Get, upsert (PUT), delete
│ ├── stats.rs # Public stats endpoint (GET /stats)
│ └── admin/ # Admin moderation module
│ ├── mod.rs # Route declarations (role-protected)
│ ├── dto.rs # Admin DTOs
│ └── handlers.rs # Stats, plugin/user mgmt, audit logs
│
├── frontend/ # Next.js frontend
│ ├── package.json # NPM dependencies
│ ├── Dockerfile # Production multi-stage build (standalone)
│ ├── Dockerfile.dev # Development container
│ ├── .dockerignore # Docker build context exclusions
│ ├── next.config.ts # Next.js configuration (images, remote patterns, security headers)
│ ├── tsconfig.json # TypeScript configuration
│ ├── eslint.config.mjs # ESLint flat config
│ ├── postcss.config.mjs # PostCSS with @tailwindcss/postcss
│ ├── vitest.config.ts # Vitest test configuration
│ ├── vitest.setup.ts # Test setup (mocks, environment)
│ ├── lib/
│ │ ├── types.ts # API DTOs (UserProfile, PluginSummary, MediaItem, ChangelogResponse, ReviewSummary, etc.)
│ │ ├── api.ts # Fetch helpers & SWR key generators (plugins, auth, api-keys, notifications, media, changelog)
│ │ ├── hooks.ts # SWR hooks (usePlugins, useApiKeys, useNotifications, useMedia, useChangelog, useReviews, etc.)
│ │ └── category-icons.ts # Shared Lucide icon map for the 17 plugin categories
│ ├── components/
│ │ ├── layout/ # Navbar (with NotificationBell), Footer, NotificationProvider (Sonner)
│ │ ├── notifications/ # NotificationBell (dropdown with unread badge)
│ │ └── ui/ # Badge, Button, PluginCard, PluginIcon, FormField, Pagination, DownloadChart
│ └── app/
│ ├── layout.tsx # Root layout (next/font, metadata, NotificationProvider)
│ ├── globals.css # Design tokens (@theme inline) & global styles
│ ├── page.tsx # Landing page
│ ├── error.tsx # Global error boundary
│ ├── global-error.tsx# Root error boundary (500)
│ ├── not-found.tsx # Custom 404 page
│ ├── loading.tsx # Global loading skeleton
│ ├── _components/ # Landing sub-components (Hero, Trending, Features, CTA, Ticker)
│ ├── auth/
│ │ ├── login/ # Login page
│ │ ├── register/ # Registration page
│ │ ├── forgot-password/ # Password recovery request
│ │ ├── reset-password/ # Password reset form
│ │ └── verify-email/ # Email verification handler
│ ├── explorer/
│ │ ├── page.tsx # Explorer with Suspense boundary
│ │ └── _components/# ExplorerContent, ExplorerSidebar, ExplorerResults
│ ├── plugins/[slug]/
│ │ ├── page.tsx # Plugin detail page
│ │ └── _components/# PluginHeader, PluginContent, PluginSidebar,
│ │ # GalleryTab (media lightbox), ChangelogTab (Markdown renderer),
│ │ # Lightbox (keyboard-navigable carousel), MediaUpload (drag-and-drop)
│ ├── dashboard/ # Author dashboard (plugins, analytics, API keys, notifications)
│ │ ├── page.tsx # KPIs, download charts, plugin list
│ │ ├── api-keys/ # API key management page
│ │ └── notifications/ # Notification center page
│ ├── profile/ # Profile editing (avatar, bio, links)
│ ├── users/[username]/ # Public author profile
│ └── admin/ # Admin moderation panel (stats, plugins, users, audit)
│
├── docs/ # Documentation (GitHub Pages)
│ └── mockup/ # Design system HTML mockups
│
├── docker-compose.yml # Dev orchestration (6 services)
├── docker-compose.prod.yml # Production stack (GHCR images, health checks)
├── .env.production.example # Production environment variables template
└── .github/
├── workflows/
│ ├── ci.yml # CI pipeline (lint, test, coverage, SonarQube)
│ ├── docker.yml # Docker image build & push to GHCR
│ └── docs.yml # Documentation deployment to GitHub Pages
└── instructions/ # Copilot/AI instructions
Backend — Rust API
Guiding Principles
- Testability —
build_app(config, pool)inlib.rsbuilds the app without network binding, enabling integration tests. - Separation of Concerns — Configuration, state, errors and routes live in separate modules.
- Graceful Shutdown — Listens for CTRL+C and SIGTERM for clean connection teardown.
Middleware Stack (Tower)
Middlewares are applied in the following order via ServiceBuilder:
| Order | Middleware | Role |
|---|---|---|
| 1 | SetRequestIdLayer | Assigns a unique UUID (x-request-id) to each request |
| 2 | TraceLayer | Structured tracing with per-request span |
| 3 | PropagateRequestIdLayer | Propagates request ID in response headers |
| 4 | TimeoutLayer | Global 30s timeout → 504 Gateway Timeout |
| 5 | CompressionLayer | Gzip response compression |
| 6 | CorsLayer | Configurable multi-origin CORS policy |
| 7 | api_key_middleware | API key pre-resolution, per-key rate limiting (configurable quotas), IP-based fallback (30 burst, 1/s), and usage audit trail |
| 8 | GovernorLayer (auth) | Strict per-IP rate limiting (5 burst, 1/4s) on auth-sensitive endpoints |
Configuration Management
The Config struct is loaded from environment variables via
Config::from_env(). A typed ConfigError is returned for
missing or invalid variables. The API fails at startup (fail-fast) if the configuration
is incorrect.
Error Handling
The AppError type unifies all application errors and implements Axum's
IntoResponse for automatic conversion to HTTP JSON responses:
| Variant | HTTP Status | Behavior |
|---|---|---|
NotFound | 404 | Resource not found |
UnprocessableEntity | 422 | Invalid data (message forwarded to client) |
Unauthorized | 401 | Unauthorized access |
Forbidden | 403 | Authenticated but not authorized (ownership check) |
Conflict | 409 | Resource conflict (e.g., duplicate slug) |
Internal | 500 | Internal error (logged server-side, never exposed) |
Routing
Routes are organized in feature modules and assembled in routes/mod.rs
under the /api/v1 prefix. To add a new feature, create a
routes/{feature}.rs file and merge it into v1_routes().
Database — PostgreSQL
Schema Overview
Migrations are managed by SQLx and run automatically on startup.
All tables use UUID primary keys and TIMESTAMPTZ timestamps.
┌────────────────┐ ┌────────────────────┐ ┌────────────────┐
│ users │ │ plugins │ │ versions │
├────────────────┤ ├────────────────────┤ ├────────────────┤
│ id PK │◄─────│ author_id FK │ │ id PK │
│ github_id │ │ id PK │◄─────│ plugin_id FK │
│ username UQ │ │ slug UQ │ │ version │
│ display_name │ │ name │ │ changelog │
│ email UQ* │ │ short_description │ │ pumpkin_min │
│ avatar_url │ │ description │ │ pumpkin_max │
│ bio │ │ license │ │ downloads │
│ password_hash │ │ downloads_total │ │ is_yanked │
│ email_verified │ │ icon_storage_key │ │ published_at │
│ is_active │ │ is_active │
│ is_active │ └────────────────────┘ └────────────────┘
│ role CHK │ │ │
│ created_at │ ┌─────┴──────────────────┤
└───────┬────────┘ │ plugin_categories │ ┌────────────────┐
│ ├────────────────────────┤ │ categories │
│ │ plugin_id FK PK │ ├────────────────┤
│ │ category_id FK PK │─────►│ id PK │
│ └────────────────────────┘ │ name UQ │
│ │ slug UQ │
┌───────┴────────┐ │ description │
│ auth_providers │ │ icon │
├────────────────┤ └────────────────┘
│ id PK │
│ user_id FK │
│ provider CHK │ (github, google, discord)
│ provider_id │
│ created_at │
│ UQ(provider, │
│ provider_id) │
└────────────────┘
┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────┐
│ email_verification_tokens │ │ password_reset_tokens │ │ audit_logs │
├──────────────────────────┤ ├──────────────────────────┤ ├──────────────────────┤
│ id PK │ │ id PK │ │ id PK │
│ user_id FK │ │ user_id FK │ │ actor_id FK │
│ token_hash │ │ token_hash │ │ action │
│ expires_at │ │ expires_at │ │ target_type │
└──────────────────────────┘ └──────────────────────────┘ │ target_id │
TTL: 24 hours TTL: 1 hour │ details (JSONB) │
Single-use (SHA-256 hash) Single-use (SHA-256 hash) │ created_at │
└──────────────────────┘
* email: unique partial index (WHERE email IS NOT NULL)
┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────┐
│ download_events │ │ api_keys │ │ notifications │
├──────────────────────────┤ ├──────────────────────────┤ ├──────────────────────┤
│ id PK │ │ id PK │ │ id PK │
│ plugin_id FK │ │ user_id FK │ │ user_id FK │
│ version_id FK │ │ name │ │ kind │
│ platform │ │ key_hash │ │ title │
│ downloaded_at │ │ key_prefix │ │ body │
└──────────────────────────┘ │ permissions TEXT[] │ │ link │
FK → plugins, versions │ last_used_at │ │ is_read │
Indexes on (plugin_id, │ expires_at │ │ created_at │
downloaded_at DESC) │ created_at │ └──────────────────────┘
└──────────────────────────┘ FK → users(id)
FK → users(id) Indexes on (user_id,
Indexes on (user_id), is_read, created_at)
(key_hash)
┌──────────────────────────┐ ┌──────────────────────────┐
│ plugin_media │ │ plugin_changelogs │
├──────────────────────────┤ ├──────────────────────────┤
│ id PK │ │ id PK │
│ plugin_id FK │ │ plugin_id FK UNIQUE │
│ media_type CHK │ │ content TEXT │
│ s3_key │ │ source CHK │
│ original_filename │ │ created_at │
│ file_size │ │ updated_at │
│ mime_type │ └──────────────────────────┘
│ caption │ FK → plugins(id) CASCADE
│ position │ source IN (manual, github)
│ created_at │ UNIQUE(plugin_id): one per plugin
└──────────────────────────┘
FK → plugins(id) CASCADE
media_type IN (image, video)
Max 10 items per plugin
Key Constraints
| Table | Constraint | Type |
|---|---|---|
users | username | UNIQUE |
users | email (case-insensitive, WHERE NOT NULL) | UNIQUE INDEX |
users | role IN (admin, moderator, author) | CHECK |
users | email_verified | BOOLEAN DEFAULT FALSE |
users | is_active | BOOLEAN DEFAULT TRUE |
auth_providers | (provider, provider_id) | UNIQUE |
auth_providers | user_id → users(id) | FK CASCADE |
auth_providers | provider IN (github, google, discord) | CHECK |
plugins | slug | UNIQUE |
plugins | author_id → users(id) | FK CASCADE |
versions | (plugin_id, version) | UNIQUE |
versions | plugin_id → plugins(id) | FK CASCADE |
plugin_categories | (plugin_id, category_id) | COMPOSITE PK |
email_verification_tokens | user_id → users(id) | FK CASCADE |
password_reset_tokens | user_id → users(id) | FK CASCADE |
audit_logs | actor_id → users(id) | FK SET NULL |
download_events | plugin_id → plugins(id) | FK CASCADE |
download_events | version_id → versions(id) | FK CASCADE |
download_events | (plugin_id, downloaded_at DESC) | INDEX |
api_keys | user_id → users(id) | FK CASCADE |
api_keys | key_hash | INDEX |
notifications | user_id → users(id) | FK CASCADE |
notifications | (user_id, is_read, created_at DESC) | INDEX |
categories | name, slug | UNIQUE |
categories | is_active | BOOLEAN DEFAULT TRUE |
categories | display_order | INTEGER DEFAULT 999 |
plugin_media | plugin_id → plugins(id) | FK CASCADE |
plugin_media | media_type IN (image, video) | CHECK |
plugin_media | Max 10 items per plugin (enforced in handler) | BUSINESS RULE |
plugin_changelogs | plugin_id → plugins(id) | FK CASCADE |
plugin_changelogs | plugin_id | UNIQUE (one per plugin) |
plugin_changelogs | source IN (manual, github) | CHECK |
Migrations
SQL migrations live in api/migrations/ and are executed automatically by
SQLx at startup. Each migration has an .up.sql and .down.sql
script for reversibility. A seed migration populates the database with realistic test data
for local development.
Frontend — Next.js
App Router Structure
The frontend uses the App Router from Next.js 16 with React 19. Styles
are managed by Tailwind CSS 4 with the PostCSS plugin. Data fetching uses SWR
with typed hooks. Fonts are loaded via next/font/google for optimal performance.
Design System — Brutalist Industrial
The project's design system follows these principles:
- Typography — Raleway (headings and body) + JetBrains Mono (code and technical data), loaded via
next/font - Palette — Black background (#0a0a0a), neutral text (#e5e5e5), orange accent (#f97316)
- Geometry — Zero border-radius, thin neutral borders (#262626)
- Effects — Noise texture overlay, subtle scanlines, orange glow on focus
Design tokens are defined in globals.css using Tailwind CSS 4 @theme inline blocks.
Shared UI components (Badge, Button, PluginCard, Navbar, Footer)
live in frontend/components/.
Pages
| Page | Route | Description |
|---|---|---|
| Landing | / | Hero section, trending bento grid, feature cards, CTA, live ticker |
| Explorer | /explorer | Meilisearch-powered sidebar filters, faceted search, paginated results |
| Plugin Detail | /plugins/[slug] | Install panel, tabbed content (Overview / Versions / Dependencies / Gallery / Changelog / Reviews), sidebar stats |
| Plugin Create | /plugins/new | Authenticated plugin submission form |
| Plugin Edit | /plugins/[slug]/edit | Pre-populated edit form (ownership enforced) |
| Auth | /auth | Sign-in/sign-up forms with email/password and OAuth provider buttons |
| Reset Password | /auth/reset-password | Token-based password reset form |
| Verify Email | /auth/verify-email | Email verification via token |
| Profile | /profile | Editable display name, bio, and avatar upload |
| Dashboard | /dashboard | Advanced KPIs, download charts, plugin list with stats and quick-action links |
| API Keys | /dashboard/api-keys | Create, list, and revoke API keys with scoped permissions |
| Notifications | /dashboard/notifications | Paginated notification center with filtering and mark-as-read |
| Public Profile | /users/[username] | Public author profile with bio, stats, and plugin portfolio |
| Admin Panel | /admin | Moderation dashboard: stats, plugin/user management, audit logs |
| 404 Not Found | — | Custom 404 page consistent with Brutalist Industrial design |
| 500 Error | — | Custom error page with recovery actions |
API Layer
The frontend communicates with the Rust API via a typed layer in frontend/lib/:
types.ts— TypeScript DTOs mirroring API response shapes (UserProfile,PluginSummary,ApiKeySummary,NotificationItem,AuthorDashboardStats,MediaItem,ChangelogResponse,ReviewSummary, etc.)api.ts— Fetch helpers and SWR key generators withcredentials: "include"for cookie auth, plus mutation helpers for API keys, notifications, media, and changeloghooks.ts— SWR hooks (usePlugins,usePlugin,useCategories,useCurrentUser,useAuthorDashboardStats,useApiKeys,useNotifications,useUnreadCount,useMedia,useChangelog,useReviews) with typed parameterscategory-icons.ts— SharedRecord<string, LucideIcon>map from category slug to Lucide icon component, used byCategoryPicker,ExplorerSidebar, and plugin forms
Docker Infrastructure
The development environment is orchestrated by docker-compose.yml with
6 services. PostgreSQL, Meilisearch and MinIO data are persisted via Docker volumes.
The Cargo cache is also mounted as a volume to speed up recompilations.
docker-compose.yml file is intended for local development only.
Passwords and keys are default values that must not be used in production.
Production Stack
The production environment uses docker-compose.prod.yml with pre-built images
from GitHub Container Registry (GHCR). Images are built and pushed automatically by the
docker.yml GitHub Actions workflow after every successful CI run.
| Service | Image | Port | Notes |
|---|---|---|---|
| Frontend | ghcr.io/fablrc/pumpkin-hub-frontend | 3000 | Next.js standalone, Docker HEALTHCHECK |
| API | ghcr.io/fablrc/pumpkin-hub-api | 8082:8080 | Alpine-based static Rust binary, no OpenSSL |
| PostgreSQL | postgres:16 | 5432 | Health check via pg_isready |
| Meilisearch | getmeili/meilisearch:v1.7 | 7700 | Health check via /health |
Deployment target: Hetzner VPS + Coolify (self-hosted PaaS) with Traefik reverse proxy and Let's Encrypt HTTPS. Object storage uses Cloudflare R2.
Persisted Volumes
| Volume | Content |
|---|---|
db-data-dev | PostgreSQL data |
meili-data-dev | Meilisearch indexes |
cargo-cache | Cargo registry and build cache |
minio-data-dev | MinIO object storage (binary artifacts) |
Object Storage — S3 / MinIO / R2
Binary artifacts (compiled plugins) are stored in an S3-compatible object store.
In development, MinIO runs inside Docker. In production, Cloudflare R2 (or any S3-compatible
service) is the intended target. The abstraction lives in api/src/storage/mod.rs
as the ObjectStorage struct.
Two-Client Design
AWS SigV4 (the request signing algorithm used by the AWS SDK) includes the
Host header in the signature calculation. This means a presigned URL
signed for host minio-dev:9000 (the Docker-internal endpoint) will be rejected
when the browser attempts to use it via localhost:9000 — the signatures do not
match. To solve this, ObjectStorage maintains two separate S3 clients:
┌─────────────────────────────────────────────────────────────────────┐
│ ObjectStorage │
│ │
│ client presign_client │
│ endpoint: S3_ENDPOINT_URL endpoint: S3_PUBLIC_URL │
│ (Docker-internal / private) (browser-reachable / public) │
│ │
│ ├─ put_object() ├─ presigned_download_url() │
│ └─ delete_object() └─ (URL host signed for public) │
└─────────────────────────────────────────────────────────────────────┘
If S3_PUBLIC_URL is not set, both clients share the same endpoint.
Upload Flow
Files are uploaded by the API directly — the browser sends the binary to the API via
multipart/form-data, and the API streams it to the object store over the
internal Docker network. In production, a Next.js upload proxy
(/api/upload/*) routes browser uploads through the frontend service to the
API via the internal Docker network (NEXT_INTERNAL_API_URL=http://api:8080),
avoiding CORS issues. If R2 presigning fails, the API falls back to an internal upload
endpoint and returns the proxied URL to the client.
Browser API (Axum) MinIO / S3
(localhost) (port 8080) (internal: minio-dev:9000)
│ │ │
│ POST /binaries │ │
│ multipart/form-data ──►│ │
│ │ PutObject │
│ │ (via `client`) │
│ │──────────────────────────►│
│ │ 200 OK │
│ │◄──────────────────────────│
│ 201 Created (binary) │ │
│◄────────────────────────│ │
Download Flow — Presigned URL
Instead of proxying file downloads through the API, a time-limited presigned URL
(1 hour TTL) is returned to the browser. The browser then downloads the file directly
from the object store, bypassing the API entirely. The URL is signed using
presign_client, whose endpoint is the public URL, so the
signature and the host the browser contacts are identical.
Browser API (Axum) MinIO / S3
(localhost) (port 8080) (public: localhost:9000)
│ │ │
│ GET /binaries/:id │ │
│ /download ────────────►│ │
│ │ GetObject presigned │
│ │ (via `presign_client`, │
│ │ host = localhost:9000) │
│ │──────────────────────────►│
│ │ presigned URL │
│ │◄──────────────────────────│
│ { url, expires_in } │ │
│◄────────────────────────│ │
│ │
│ GET https://localhost:9000/pumpkin-hub-binaries/ │
│ ...?X-Amz-Signature=<sig>&X-Amz-Expires=3600 │
│─────────────────────────────────────────────────────►│
│ binary payload │
│◄─────────────────────────────────────────────────────│
✓ Host in URL (localhost:9000) matches the host used during signing
→ MinIO accepts the request.
S3_PUBLIC_URL is omitted (or wrong) in a Docker environment, the presigned
URL will carry the internal hostname (minio-dev:9000) while the browser tries
to reach localhost:9000. MinIO will return a
SignatureDoesNotMatch error because the Host header was
included in the signature for a different hostname.
Dynamic URL Resolution (Icons & Gallery)
Plugin icons and gallery media are stored in S3 with their storage key
persisted in the database (e.g. icon_storage_key, storage_key).
URLs are never stored in the database — they are generated dynamically
at query time via ObjectStorage::resolve_url():
- If
S3_USE_DIRECT_URLS=trueandS3_PUBLIC_URLis set, a stable direct URL is built from the storage key (no expiry). - Otherwise, a presigned URL (1-hour TTL) is generated on-the-fly.
This design avoids broken images caused by expired presigned URLs that were previously
stored in the database at upload time. The Meilisearch index also stores
icon_storage_key and the search handler resolves URLs before
returning results to the frontend.
S3_USE_DIRECT_URLS=true
and S3_FORCE_PATH_STYLE=false. This produces stable, non-expiring URLs
via the R2 public endpoint and is the recommended configuration for serving images.
Dev vs Production Configuration
| Variable | Dev (Docker / MinIO) | Prod (Cloudflare R2) |
|---|---|---|
S3_ENDPOINT_URL |
http://minio-dev:9000 |
https://<account>.r2.cloudflarestorage.com |
S3_PUBLIC_URL |
http://localhost:9000 |
https://pub-<hash>.r2.dev (R2 public URL) or your custom domain |
S3_BUCKET |
pumpkin-hub-binaries |
Your R2 bucket name |
S3_ACCESS_KEY_ID |
minioadmin |
R2 API Token ID |
S3_SECRET_ACCESS_KEY |
minioadmin |
R2 API Token Secret |
S3_REGION |
us-east-1 (MinIO ignores this) |
auto (R2 requirement) |
S3_FORCE_PATH_STYLE |
true (required for MinIO) |
false (R2 uses virtual-hosted style) |
S3_USE_DIRECT_URLS |
false |
true — serve files via direct public URLs (no presigning) when using public R2 buckets |
Object Key Structure
Files are stored with the following canonical key patterns:
# Plugin binaries
plugins/{plugin_slug}/{version}/{platform}/{file_name}
# Plugin icons
plugins/{plugin_slug}/icon/{uuid}.{ext}
# Gallery media
plugins/{plugin_slug}/media/{media_id}/{file_name}
# Example
plugins/pumpkin-voice/0.1.2/windows/pumpkin_voice.dll
plugins/pumpkin-voice/icon/550e8400-e29b-41d4-a716-446655440000.png
plugins/pumpkin-voice/media/a1b2c3d4-e5f6-7890-abcd-ef1234567890/screenshot.png
Only the storage key is persisted in the database
(icon_storage_key, storage_key). Browser-reachable URLs
are resolved dynamically at query time — never stored.
CI/CD — GitHub Actions
Three GitHub Actions workflows automate quality, delivery, and documentation:
CI Pipeline (ci.yml)
Runs on every push and pull request targeting master and develop.
Three sequential stages: Frontend → Backend → SonarQube.
Frontend CI
- Lint —
npm run lint(ESLint, zero warnings) - Type-check —
npx tsc --noEmit - Build —
npm run build - Tests & Coverage —
npm run test:coverage(Vitest) — hard gate: lines 80%, functions 80%, branches 75%
Backend CI
- Formatting —
cargo fmt --all -- --check - Static Analysis —
cargo clippy -- -D warnings - Migrations — run against PostgreSQL service container
- Tests & Coverage —
cargo tarpaulin --out lcov
SonarQube
Runs after both CI stages. Combines frontend (LCOV) and backend (LCOV) coverage reports for unified code quality analysis.
Docker Build & Deploy (docker.yml)
Triggered on successful CI completion on master. Builds multi-platform Docker
images via Buildx and pushes to GitHub Container Registry:
ghcr.io/fablrc/pumpkin-hub-frontend:latest(+ commit SHA tag)ghcr.io/fablrc/pumpkin-hub-api:latest(+ commit SHA tag)
After pushing, a Coolify webhook is triggered to automatically deploy the new images to the production server.
Documentation (docs.yml)
Deploys the docs/ directory to GitHub Pages when documentation files
change on master.
package-lock.json), Rust cache (Swatinem/rust-cache),
and Docker layer cache (actions/cache) are enabled to reduce CI build times.