Turn ZPan into a proper image hosting service with permanent URLs, custom domains, and ecosystem tool support.
- 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
mattersnorshares. 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.
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
createdAtBecause 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 → presignImplications:
- 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_historytable without any storage touching.
Storage lives under the ih/ prefix in the org's existing S3/R2 bucket. Bytes count against orgQuotas alongside files.
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).
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.
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.
POST /api/ihost/upload,multipart/form-data.- Auth: better-auth
apiKeyplugin (Authorization: Bearer <key>), not a custom token table. Keys are organization-owned via the existingorganizationplugin, carry animage-hosting:uploadpermission, 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": "",
"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.
- Validated: no
.., no leading/, charset[a-zA-Z0-9._/\-], max depth 5, max total length 256. - On collision (same
orgId+ samepath): 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).
| 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.
- 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 | 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.
Configured per org via image_hosting_configs.customDomain.
Uses Cloudflare for SaaS / Custom Hostnames API:
- User enters
img.myblog.comin settings. - ZPan calls
POST /zones/<zone_id>/custom_hostnameswith the hostname and metadata binding to our Worker. - ZPan shows: "Add CNAME
img.myblog.com→ssl.zpan.ioat your DNS provider." - User updates DNS. CF detects via HTTP validation, auto-issues cert (~90 s), and
domainVerifiedAtis set. - CF routes the new hostname to ZPan's Worker;
imageHostingDomainmiddleware 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.
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.
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.
| 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 |
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.
- 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+.
- Clipboard paste (screenshot → URL)
- Drag-and-drop batch upload with per-file progress
- Virtual folder tree built from
pathprefixes; 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)
Files ▼
Photos / Videos / Music / Documents
Shares
Trash
────── divider ──────
Image Host ← hidden until opt-in enabled for the active orgLabel: Image Host (noun, sidebar-length-safe). Feature name everywhere else (docs, settings page title, landing): Image Hosting.
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_history301 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
- Programmatic upload API for PicGo / Typora / Obsidian workflows
- Permanent public URLs under user-owned domains
- ShareX / screenshot-tool ecosystem
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).
| 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.
Blogger writing in Obsidian:
I paste a screenshot. PicGo, authenticated with my ZPan API key, uploads it to my personal org and inserts
. 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 intohero/2026/; engineers paste screenshots via uPic with their own API keys intoscreenshots/{year}/{month}/. Everything lives under the same team domain, organized automatically, visible to every member.