docs: update v2.4 roadmap and specifications

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
saltbo
2026-04-21 10:40:49 -04:00
parent 0137f009d4
commit b62ff49469
2 changed files with 258 additions and 20 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
# ZPan 2.0 Roadmap
> **The open-source, S3-native file hosting platform.**
> Free to self-host. One-click deploy to Cloudflare. Image bed, file sharing, and backup — all in one.
> Free to self-host. One-click deploy to Cloudflare. Image hosting, file sharing, and backup — all in one.
## Product Positioning
@@ -9,7 +9,7 @@ ZPan is a lightweight file hosting platform built on S3-compatible storage. Not
Three scenarios, one platform:
- **Image Bed** — Upload via PicGo / ShareX / API, get a URL
- **Image Hosting** — Upload via PicGo / uPic / ShareX / API, get a permanent URL on your own domain
- **File Sharing** — Upload, generate a link, distribute
- **File Backup & Sync** — CLI agent syncs local directories to S3/R2
@@ -31,7 +31,7 @@ Each version ships 12 major features. Ship small, ship often.
| [v2.1](docs/roadmap/v2.1.md) | **Auth & Access** | Social login, OIDC, invite codes, registration controls |
| [v2.2](docs/roadmap/v2.2.md) | **Teams** | Team workspaces, shared folders, member roles |
| [v2.3](docs/roadmap/v2.3.md) | **Sharing** | Share links, direct links, password / expiration / limits |
| [v2.4](docs/roadmap/v2.4.md) | **Image Bed** | Upload API, PicGo / ShareX integration |
| [v2.4](docs/roadmap/v2.4.md) | **Image Hosting** | Upload API, PicGo / uPic / ShareX integration, custom domain |
| [v2.5](docs/roadmap/v2.5.md) | **Branding & Polish** | Custom logo / title, dark mode, custom file domain |
| [v2.6](docs/roadmap/v2.6.md) | **Backup** | zpan-cli (Rust) one-way backup for NAS / desktop |
| [v2.7](docs/roadmap/v2.7.md) | **Sync & Desktop** | Bidirectional sync + Tauri desktop tray app |
+255 -17
View File
@@ -1,23 +1,258 @@
# v2.4 — Image Bed
# v2.4 — Image Hosting
Turn ZPan into a proper image bed with tool ecosystem integration.
Turn ZPan into a proper image hosting service with permanent URLs, custom domains, and ecosystem tool support.
## Major Features
## Design Principles
### Upload API
- REST API for programmatic file upload (token-authenticated)
- API returns configurable URL formats: raw URL, Markdown `![](url)`, HTML `<img>`, BBCode
- **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.
### Tool Integrations
- **PicGo / PicList compatible endpoint** — works with Typora, Obsidian, and other Markdown editors
- **ShareX Custom Uploader** — `.sxcu` config file generator in the web UI, double-click to configure
- **Flameshot** compatible — same upload protocol as ShareX
## Data Model
### Upload Experience
- Clipboard paste upload in web UI (paste screenshot, get URL instantly)
- Batch upload with progress indicators
- Upload history panel — recently uploaded files with one-click URL copy
- Auto-copy URL to clipboard after upload (configurable format)
Two new tables. Neither touches `matters` or `shares`.
```ts
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`:
```ts
// 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`):
```json
{
"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.com``ssl.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
@@ -42,7 +277,10 @@ When these vars are absent (Node / Docker self-host), domain registration is a n
## User Scenarios
**Blogger writing in Obsidian:**
> I paste a screenshot into Obsidian, PicGo uploads it to my ZPan, and inserts `![](https://img.myblog.com/a1b2c3.png)`. I never leave my editor.
> 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):**
> I press PrintScreen, ShareX captures my screen, uploads to ZPan in the background, and puts the URL in my clipboard. I paste it into Slack. Total time: 2 seconds.
> 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.