Skip to content

Latest commit

 

History

History
286 lines (203 loc) · 14.4 KB

File metadata and controls

286 lines (203 loc) · 14.4 KB

v2.4 — Image Hosting

Turn ZPan into a proper image hosting service with permanent URLs, custom domains, and ecosystem tool support.

Design Principles

  • Independent product face, not a file-system view. Image Hosting has its own sidebar entry below Trash, visually separated by a divider. Opt-in per workspace, invisible until enabled.
  • Workspace-scoped. Every org (personal or team) has at most one image hosting space. Storage, quota, tokens, domain, and members all inherit the org boundary.
  • Decoupled from files and shares. Hosted images are neither matters nor shares. Uploading creates a dedicated hosting record; the Files view never shows hosted images.
  • Permanent URLs are the core promise. No expiring signed links exposed to users. URLs are stable for the lifetime of the image.
  • Virtual paths are metadata, not storage layout. Storage is flat and ID-keyed; the user-visible path is a database field. Reorganizing folders is a DB update — zero S3 operations, zero egress cost. This is Cloudinary's "Dynamic Folder Mode" pattern.
  • Hard delete, no trash. Delete is immediate and permanent, protected only by a 5-second undo toast. Matches industry norms (Imgur, Chevereto, SM.MS) and keeps the URL contract honest.

Data Model

Two new tables. Neither touches matters or shares.

image_hosting_configs
  orgId PK                  // 1-to-1 with org; row exists ⇒ feature is enabled
  customDomain UNIQUE       // "img.myblog.com"; null = default app host only
  domainVerifiedAt          // set once CF custom hostname validates
  refererAllowlist          // JSON array, nullable (empty/null = allow all)
  createdAt

image_hostings
  id PK                     // opaque internal id, also drives storageKey
  orgId
  token UNIQUE              // "ih_" + nanoid(10); supports token URL access
  path                      // user-visible virtual path, e.g. "blog/2026/04/screenshot.png"
  UNIQUE (orgId, path)      // path must be unique within an org
  storageKey                // "ih/<orgId>/<id>" — flat, ID-keyed, independent of path
  size, mime, width, height
  accessCount, lastAccessedAt
  createdAt

Storage vs access decoupling

Because every read is a 302 redirect, the storage key never appears in user-facing URLs. Storage is deliberately flat:

S3:   ih/<orgId>/<id>                               ← stable, never changes
DB:   path = "blog/2026/04/screenshot.png"          ← pure metadata
URL:  img.myblog.com/blog/2026/04/screenshot.png    ← resolves path → id → storageKey → presign

Implications:

  • Rename / move / bulk reorganize = one DB UPDATE. No S3 CopyObject, no cost, no latency.
  • Admin browsing R2 console sees opaque keys. Mitigated by a Web UI admin view that lists images by path. For disaster recovery, D1 backups are authoritative — a lost D1 would leave unlabeled blobs, same as any metadata-driven object store.
  • Future-proof for path-history redirects (v2.5+). When path changes, old URL can 301 to new via a path_history table without any storage touching.

Storage lives under the ih/ prefix in the org's existing S3/R2 bucket. Bytes count against orgQuotas alongside files.

URL Scheme

Two URL forms coexist, selected by domain:

Domain Accepted URL shape Example
Default app domain (zpan.io) Token URL only: /r/:token https://zpan.io/r/ih_aB3xK9.png
Custom domain (img.user.com) Path URL only: /<virtualPath> https://img.user.com/blog/2026/04/screenshot.png

Rationale for the strict split:

  • Default domain is multi-tenant — path URLs would collide across orgs and leak implementation.
  • Custom domain is single-tenant by definition — serving the full path at root is clean and matches user expectations (SM.MS, Lsky, Chevereto all do this).
  • A single image is always reachable via both forms. The Web UI's "Copy URL" picks the best one per org (path URL when a custom domain is configured, token URL otherwise).

Route: /r/:token

Shared with direct shares (old /d/:token is removed outright; no users in production, no alias kept). Token prefix disambiguates:

Prefix Kind Cache-Control Content-Disposition
ds_ Direct share (existing) no-store attachment
ih_ Image hosting public, max-age=300 inline

Optional file extension for Markdown / browser hinting: /r/ih_aB3xK9.png. Server ignores the extension when resolving — only the token is authoritative. Extension is derived from stored mime at upload.

max-age=300 is deliberately shorter than PRESIGN_TTL_SECS so cached 302s never point to expired presigned URLs.

Host-based dispatch

A top-level Hono middleware routes by Host:

// server/middleware/image-hosting-domain.ts
export async function imageHostingDomain(c: Context, next: Next) {
  const host = c.req.header('host')?.toLowerCase()
  if (!host || host === DEFAULT_APP_HOST || host.endsWith(`.${DEFAULT_APP_HOST}`)) {
    return next()
  }

  const orgId = await resolveCustomDomain(c.get('platform').db, host)
  if (!orgId) return next()  // unregistered host — normal 404 flow

  const virtualPath = c.req.path.replace(/^\/+/, '')
  return handleImageByPath(c, orgId, virtualPath)
}

// server/app.ts
app.use('*', imageHostingDomain)
app.route('/r', redirectRouter)
app.route('/api', apiRouter)
// ...

handleImageByPath does a single-row lookup via UNIQUE(orgId, path), checks referer policy, presigns storageKey, and 302-redirects. ~30 lines total. resolveCustomDomain hits D1 directly in v2.4; KV caching deferred to v2.5+ if traffic warrants.

Upload API

  • POST /api/ihost/upload, multipart/form-data.
  • Auth: better-auth apiKey plugin (Authorization: Bearer <key>), not a custom token table. Keys are organization-owned via the existing organization plugin, carry an image-hosting:upload permission, and inherit the plugin's revocation / lastUsedAt / optional expiration machinery.
  • Form fields:
Field Required Description
file yes Binary image data
path optional User-resolved virtual path, e.g. blog/2026/04/screenshot.png. Defaults to the filename at root if omitted.
  • Response (fixed shape so PicGo / uPic / ShareX all extract via JSONPath data.url):
{
  "data": {
    "url":      "https://img.myblog.com/blog/2026/04/screenshot.png",
    "urlAlt":   "https://zpan.io/r/ih_aB3xK9.png",
    "markdown": "![](https://img.myblog.com/blog/2026/04/screenshot.png)",
    "html":     "<img src=\"https://img.myblog.com/blog/2026/04/screenshot.png\" />",
    "bbcode":   "[img]https://img.myblog.com/blog/2026/04/screenshot.png[/img]"
  }
}

url prefers the custom-domain path URL when available; otherwise falls back to the token URL. urlAlt is the always-available token URL.

Path rules

  • Validated: no .., no leading /, charset [a-zA-Z0-9._/\-], max depth 5, max total length 256.
  • On collision (same orgId + same path): server auto-appends a short suffix to the filename (screenshot-ab3x.png) and returns the actual path used. No 409s thrown at users — tool templates routinely emit duplicates and users don't want to debug this.
  • Path is immutable in v2.4 (rename/move is a v2.5+ feature).

Write paths

Client Mechanism Bytes through server?
Web UI (paste / drag) Presigned PUT directly to S3 No
External tools (PicGo / uPic / ShareX) POST /api/ihost/upload stream-proxied to R2 via Workers Request.body → R2 PutObject (no disk buffering) Yes, streaming

The proxy path is forced for external tools because they all assume single-POST-returns-URL semantics.

Upload constraints

  • Max size: 20 MB per image.
  • Allowed MIME: image/png, image/jpeg, image/gif, image/webp.
  • SVG rejected. Embeds JavaScript; sanitization is out of scope for v2.4.
  • No deduplication. Reuploading the same bytes produces a new row and new URL.

Tool Integrations

Tool Platform Mechanism
PicGo / PicList Win / Mac / Linux picgo-plugin-web-uploader: url, paramName=file, jsonPath=data.url, custom header for Bearer, customBody for path template
uPic macOS Custom Host type: POST, file field file, header Authorization: Bearer ..., URL path data.url, save path template feeds the path field. Web UI has a one-click "Copy uPic config" button.
ShareX Windows .sxcu config file generator in Web UI (double-click to import); variables like %y/%mo go into the path field
Flameshot Linux Same protocol as ShareX
iPic macOS Not supported. iPic accepts only fixed providers (Imgur / Qiniu / S3) with no custom REST endpoint.

All tool templates resolve client-side before upload. ZPan sees only the final resolved path string.

Custom Domain (CNAME)

Configured per org via image_hosting_configs.customDomain.

Cloudflare Workers (primary runtime)

Uses Cloudflare for SaaS / Custom Hostnames API:

  1. User enters img.myblog.com in settings.
  2. ZPan calls POST /zones/<zone_id>/custom_hostnames with the hostname and metadata binding to our Worker.
  3. ZPan shows: "Add CNAME img.myblog.comssl.zpan.io at your DNS provider."
  4. User updates DNS. CF detects via HTTP validation, auto-issues cert (~90 s), and domainVerifiedAt is set.
  5. CF routes the new hostname to ZPan's Worker; imageHostingDomain middleware dispatches based on Host.

Pricing: first 100 custom hostnames free, then $0.10/mo per hostname (requires Workers Paid, which ZPan already uses). Budget scales cleanly — 1,000 hosted domains costs roughly $90/mo.

Node self-hosted runtime

CF for SaaS is unavailable. Admin docs cover the manual path: reverse proxy (Caddy recommended for automatic ACME via Let's Encrypt) in front of the Node server with Host preserved. Automation is deferred — this reinforces CF as the ergonomic default runtime.

Apex domains

CNAMEs are invalid at the zone apex. Documented alternatives: (a) host the zone on Cloudflare and use CNAME flattening, or (b) use a subdomain. Virtually all image-hosting URLs use subdomains.

Edge cases

Scenario Outcome
User fills domain but DNS not yet pointed domainVerifiedAt stays null; Web UI shows "pending DNS"; Worker ignores the config row
Two orgs try the same custom domain UNIQUE(customDomain) blocks the second; first wins
User types someone else's domain CF for SaaS DV validation fails (they don't control DNS); no side effects

Hotlink Protection

Referer allowlist in image_hosting_configs.refererAllowlist. Enforced at every hosting read (both path URL and token URL): if allowlist is non-empty and incoming Referer doesn't match, return 403 before issuing the presigned URL. Enforced in v2.4; admin-configurable per org.

Bandwidth Accounting

  • Every read increments image_hostings.accessCount.
  • Admin dashboard shows estimated bandwidth (SUM(accessCount × size)) per org.
  • No hard enforcement in v2.4. Read path is 302-redirect, so true bytes aren't observable server-side. Approximate accounting is visible-only; quota enforcement via CF Analytics integration is v2.5+.

Upload Experience (Web UI)

  • Clipboard paste (screenshot → URL)
  • Drag-and-drop batch upload with per-file progress
  • Virtual folder tree built from path prefixes; upload dialog picks / creates target folder
  • Recent uploads grid with one-click copy (raw / Markdown / HTML / BBCode)
  • Format selector for auto-copy after upload
  • Delete: confirmation dialog + 5-second undo toast (client-side delay of the DELETE request; no persisted trash)

Sidebar Placement

Files ▼
  Photos / Videos / Music / Documents
Shares
Trash
────── divider ──────
Image Host            ← hidden until opt-in enabled for the active org

Label: Image Host (noun, sidebar-length-safe). Feature name everywhere else (docs, settings page title, landing): Image Hosting.

Explicitly Out of Scope

Deferred to v2.5+:

  • Image transformations (resize, WebP conversion, compression) — likely CF Images
  • Bandwidth quota enforcement (requires CF Analytics for precise accounting)
  • Path rename / move and path_history 301 redirects
  • SVG sanitization
  • Custom domain lookup KV cache (drop in when traffic warrants)

Never planned:

  • Public galleries / waterfall browse (ZPan is not Unsplash)
  • Content-hash deduplication
  • Multiple custom domains per org

v1 Issues Resolved

  • Programmatic upload API for PicGo / Typora / Obsidian workflows
  • Permanent public URLs under user-owned domains
  • ShareX / screenshot-tool ecosystem

Config API

Image Hosting Config (/api/ihost/config)

A single-resource REST endpoint (GET / PUT / DELETE) that lets org owners and editors manage image hosting settings:

  • Enable / disable image hosting for the org.
  • Custom domain — set a custom hostname (e.g. img.myblog.com). On Cloudflare Workers deployments, the domain is automatically registered via Cloudflare Custom Hostnames (CF for SaaS). GET lazily refreshes the verification status.
  • Referer allowlist — restrict which origins may hotlink images. Each entry must be a full origin (https://example.com).

CF Custom Hostnames env vars (Cloudflare Workers deployment)

Var Description
CF_API_TOKEN Scoped token with Zone.Custom Hostnames edit permission
CF_ZONE_ID The zone hosting the CNAME target
CF_CNAME_TARGET e.g. ssl.zpan.io

When these vars are absent (Node / Docker self-host), domain registration is a no-op and domainStatus stays pending. For Caddy-based manual setup see docs/ihost-custom-domain-node.md.

User Scenarios

Blogger writing in Obsidian:

I paste a screenshot. PicGo, authenticated with my ZPan API key, uploads it to my personal org and inserts ![](https://img.myblog.com/blog/2026/04/a1b2c3.png). The path matches my PicGo template {year}/{month}/{filename}, so my blog images auto-organize by month.

Screenshot workflow (ShareX user):

PrintScreen → ShareX captures → uploads via my generated .sxcu → URL lands in clipboard → paste into Slack. Two seconds.

Small team:

Acme team org has cdn.acme.com. Marketing uploads hero images via Web UI into hero/2026/; engineers paste screenshots via uPic with their own API keys into screenshots/{year}/{month}/. Everything lives under the same team domain, organized automatically, visible to every member.