mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
docs: update v2.4 roadmap and specifications
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+3
-3
@@ -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 1–2 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
@@ -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 ``, 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": "",
|
||||
"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 ``. I never leave my editor.
|
||||
> 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):**
|
||||
> 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.
|
||||
|
||||
Reference in New Issue
Block a user