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

Middleware Stack (Tower)

Middlewares are applied in the following order via ServiceBuilder:

OrderMiddlewareRole
1SetRequestIdLayerAssigns a unique UUID (x-request-id) to each request
2TraceLayerStructured tracing with per-request span
3PropagateRequestIdLayerPropagates request ID in response headers
4TimeoutLayerGlobal 30s timeout → 504 Gateway Timeout
5CompressionLayerGzip response compression
6CorsLayerConfigurable multi-origin CORS policy
7api_key_middlewareAPI key pre-resolution, per-key rate limiting (configurable quotas), IP-based fallback (30 burst, 1/s), and usage audit trail
8GovernorLayer (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:

VariantHTTP StatusBehavior
NotFound404Resource not found
UnprocessableEntity422Invalid data (message forwarded to client)
Unauthorized401Unauthorized access
Forbidden403Authenticated but not authorized (ownership check)
Conflict409Resource conflict (e.g., duplicate slug)
Internal500Internal 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

TableConstraintType
usersusernameUNIQUE
usersemail (case-insensitive, WHERE NOT NULL)UNIQUE INDEX
usersrole IN (admin, moderator, author)CHECK
usersemail_verifiedBOOLEAN DEFAULT FALSE
usersis_activeBOOLEAN DEFAULT TRUE
auth_providers(provider, provider_id)UNIQUE
auth_providersuser_idusers(id)FK CASCADE
auth_providersprovider IN (github, google, discord)CHECK
pluginsslugUNIQUE
pluginsauthor_idusers(id)FK CASCADE
versions(plugin_id, version)UNIQUE
versionsplugin_idplugins(id)FK CASCADE
plugin_categories(plugin_id, category_id)COMPOSITE PK
email_verification_tokensuser_idusers(id)FK CASCADE
password_reset_tokensuser_idusers(id)FK CASCADE
audit_logsactor_idusers(id)FK SET NULL
download_eventsplugin_idplugins(id)FK CASCADE
download_eventsversion_idversions(id)FK CASCADE
download_events(plugin_id, downloaded_at DESC)INDEX
api_keysuser_idusers(id)FK CASCADE
api_keyskey_hashINDEX
notificationsuser_idusers(id)FK CASCADE
notifications(user_id, is_read, created_at DESC)INDEX
categoriesname, slugUNIQUE
categoriesis_activeBOOLEAN DEFAULT TRUE
categoriesdisplay_orderINTEGER DEFAULT 999
plugin_mediaplugin_idplugins(id)FK CASCADE
plugin_mediamedia_type IN (image, video)CHECK
plugin_mediaMax 10 items per plugin (enforced in handler)BUSINESS RULE
plugin_changelogsplugin_idplugins(id)FK CASCADE
plugin_changelogsplugin_idUNIQUE (one per plugin)
plugin_changelogssource 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:

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

PageRouteDescription
Landing/Hero section, trending bento grid, feature cards, CTA, live ticker
Explorer/explorerMeilisearch-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/newAuthenticated plugin submission form
Plugin Edit/plugins/[slug]/editPre-populated edit form (ownership enforced)
Auth/authSign-in/sign-up forms with email/password and OAuth provider buttons
Reset Password/auth/reset-passwordToken-based password reset form
Verify Email/auth/verify-emailEmail verification via token
Profile/profileEditable display name, bio, and avatar upload
Dashboard/dashboardAdvanced KPIs, download charts, plugin list with stats and quick-action links
API Keys/dashboard/api-keysCreate, list, and revoke API keys with scoped permissions
Notifications/dashboard/notificationsPaginated notification center with filtering and mark-as-read
Public Profile/users/[username]Public author profile with bio, stats, and plugin portfolio
Admin Panel/adminModeration dashboard: stats, plugin/user management, audit logs
404 Not FoundCustom 404 page consistent with Brutalist Industrial design
500 ErrorCustom error page with recovery actions

API Layer

The frontend communicates with the Rust API via a typed layer in frontend/lib/:

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.

Warning
The 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.

ServiceImagePortNotes
Frontendghcr.io/fablrc/pumpkin-hub-frontend3000Next.js standalone, Docker HEALTHCHECK
APIghcr.io/fablrc/pumpkin-hub-api8082:8080Alpine-based static Rust binary, no OpenSSL
PostgreSQLpostgres:165432Health check via pg_isready
Meilisearchgetmeili/meilisearch:v1.77700Health 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

VolumeContent
db-data-devPostgreSQL data
meili-data-devMeilisearch indexes
cargo-cacheCargo registry and build cache
minio-data-devMinIO 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.
Common Mistake
If 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():

  1. If S3_USE_DIRECT_URLS=true and S3_PUBLIC_URL is set, a stable direct URL is built from the storage key (no expiry).
  2. 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.

R2 Production Tip
For Cloudflare R2 with a public bucket, set 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

VariableDev (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

  1. Lintnpm run lint (ESLint, zero warnings)
  2. Type-checknpx tsc --noEmit
  3. Buildnpm run build
  4. Tests & Coveragenpm run test:coverage (Vitest) — hard gate: lines 80%, functions 80%, branches 75%

Backend CI

  1. Formattingcargo fmt --all -- --check
  2. Static Analysiscargo clippy -- -D warnings
  3. Migrations — run against PostgreSQL service container
  4. Tests & Coveragecargo 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:

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.

Optimizations
NPM cache (package-lock.json), Rust cache (Swatinem/rust-cache), and Docker layer cache (actions/cache) are enabled to reduce CI build times.