Jasper VanandClaude Opus 4.8 705aa67a2d refactor(server): usecase-per-resource — move all handler logic into usecases, lock the http boundary (#435)
* refactor(server): enforce http→usecase boundary + extract storages usecase

Adds an AST-based lint (scripts/lint-http-boundary.ts, `pnpm lint:http`, wired
into CI) that forbids http handlers from reaching into deps ports directly
(`c.get('deps').<port>.<method>()`) — the runtime signal of business logic
leaking into the delivery layer, which dependency-cruiser's import-graph rules
cannot see. It ships with a migration ratchet of the 30 handlers that still
violate: CI fails on any new violation and on any ratcheted file that has become
clean, so the list only shrinks. When empty, the boundary is locked.

Converts storages as the first usecase-per-resource example:
- usecases/storage.ts owns all storage business rules (Community storage limit,
  egress-credit-billing feature gate, activity logging)
- http/storages.ts is now thin: validate → call usecase → serialize
- usecases/storage.test.ts exhausts the branches with fake ports (14 cases)
- storages removed from the ratchet (29 remain)

Behavior preserved: storages.integration.test.ts (24) unchanged and green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): extract profile + notification resource usecases

Converts two owner/public single-port resources to the usecase-per-resource
convention (handlers now only validate → call usecase → serialize):
- usecases/profile.ts (getPublicProfile) + usecases/notification.ts
  (list/unreadCount/markRead/markAllRead), each with fake-port unit tests
- http/profile.ts, http/notifications.ts no longer touch deps ports
- ratchet: 29 → 27

Behavior preserved: profile + notifications integration suites (23) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): extract audit + quota + announcement resource usecases

- usecases/audit.ts (listAuditEvents)
- usecases/quota.ts (listQuotaOverview with org-type parsing, getUserQuota with
  personal-org fallback)
- usecases/announcement.ts (user/admin list + CRUD)
Handlers keep only pure input parsing (pagination clamp) + serialization; no
deps-port access. Each usecase has fake-port unit tests (12 cases).
ratchet: 27 → 24

Behavior preserved: audit/quotas/announcements integration suites (65) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): extract auth-provider/background-job/email-config/invite-code/site-invitation/user usecases

Wave of six independent resources converted to usecase-per-resource (parallel
subagents, centrally verified). Each: new usecases/<resource>.ts holding all
port access + business rules, a thinned handler (validate → call usecase →
serialize, no deps-port access), and fake-port unit tests.

- auth-provider.ts: provider config list/upsert/delete; OIDC validation +
  social-login free-limit gate as outcome unions
- background-job.ts: list/get/cancel/create/retry; keeps port-thrown
  BackgroundJobError mapping
- email-config.ts: masked get / save rows / send-test (send_failed outcome)
- invite-code.ts: list/validate/generate(expiry policy)/delete outcome union
- site-invitation.ts: create/resend/revoke/getByToken; email-before-write
  ordering preserved
- user.ts: admin user status/delete + entitlement CRUD; repo-chosen failure
  statuses threaded through unchanged
ratchet: 24 → 18

Verified: typecheck, lint:http, lint:arch, biome all clean; 90 new unit tests +
124 existing integration tests green (behavior preserved).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): extract ihost/team/branding/me/trash/system resource usecases

Second parallel wave (centrally verified). Handlers thinned to validate → call
usecase → serialize; all port access + business rules moved into usecases.

- image-hosting.ts (extended) + image-hosting-config.ts: ihost upload/list/delete
  + config CRUD with CF custom-hostname lifecycle; quota→422 preserved
- team.ts: /api/teams + /api/admin/teams (invite links, join, activity feed,
  org logo, admin quota entitlements); role checks + repo-failure threading
- branding.ts (extended): admin write orchestration (logo/favicon upload,
  theme, single audit event) + reset; white_label gating stays in middleware
- me.ts: avatar upload/delete (gateway status passthrough, DB-first delete)
- trash.ts: empty-trash (reuses purge.ts; trash_empty audit only when >0)
- system.ts: instance info, changelog, system-options CRUD (signup/captcha/quota
  validation ordering preserved)

Also removed dead code: the speculative team.ts createTeamGate (the team-create
limit is enforced in auth.ts via licensing.checkTeamLimit; nothing called it).
ratchet: 18 → 10

Verified: typecheck, lint:http, lint:arch, biome clean; 148 new unit tests +
228 integration tests green (behavior preserved).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): extract cloud-store + licensing-admin + events usecases

Third parallel wave (centrally verified + test fixups).

- cloud-store.ts: storefront reads, checkout/orders, and webhook delivery
  (cloud event token verification + idempotency); binding gate as outcome union
- licensing.ts (extended, admin section): initiatePairing / pollPairing (cert
  verify + rollback) / triggerRefresh / unbindLicense
- events.ts: the multiplexed SSE stream as a (deps, params, signal, emit)
  usecase; the handler owns the ReadableStream/Response and feeds ONE
  AbortController from both teardown paths (request abort + body cancel)

Streaming fix: guard the stream controller so a consumer cancel() — which
already closes it before firing the abort listener — no longer double-closes
(ERR_INVALID_STATE), eliminating the unhandled errors in the events suite.

Test fixups (behavior was correct, verified by integration): cloud-store fake
rebuilt the bound client per request and reset its response queue (singleton
now); licensing-admin unit test forced onto the node env (paseto-ts needs a
real TextEncoder).
ratchet: 10 → 5

Verified: typecheck, lint:http, lint:arch, biome clean; 61 unit + 77 integration
tests green (behavior preserved).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): extract download-traffic metering into a usecase

traffic-metering-utils.ts was an http helper that read deps off the request and
ran the quota+egress download meter inline. Moves the decision into
cloud-traffic-metering.ts as meterDownloadTraffic / reportDownloadEgress
(deps-first, returning a plain {ok|quota_exceeded|insufficient_credits}
outcome). The http helper stays as a thin Context adapter that resolves the
cloud base URL, calls the usecase (deps passed whole), and renders the 422/402
responses — so its four consumers (shares, objects, redirect, webdav) are
unchanged and the file is now boundary-clean.
ratchet: 5 → 4

Verified: typecheck, lint:http, lint:arch, biome clean; cloud-traffic-metering
unit (13, incl. 3 new download tests) + 104 consumer integration tests
(redirect/objects-quota/share-public/cloud-traffic-metering) green — download
metering behavior preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): extract redirect + share + object resource usecases

The download-flow consumers. Each download orchestration (resolve → access/
expiry/limit gates → atomic increment → meter → presign → audit) moved into its
resource usecase, which calls meterDownloadTraffic/reportDownloadEgress(deps, …)
directly; the handler computes cloudBaseUrl, manages cookies, and renders the
route-specific 302/JSON/410/422/402 responses from the returned outcome.

- usecases/redirect.ts: /r/:token (ds_ direct share + ih_ image hosting), with
  refer-allowlist + presign-rollback
- usecases/share.ts: public + authed share routes; cookies become usecase
  *decisions* the handler applies (view-dedup, password session); imports
  save-to-drive + share-notification unchanged
- usecases/object.ts: upload sessions, confirm, list/move/trash/restore/delete,
  copy/transfer, download; keeps ObjectUploadSessionError; consolidated two
  identical write-access middlewares
- usecases/share-ref.ts: pure share-token helpers (HMAC ref codec, breadcrumb,
  access gate, presign TTL) moved out of http/share-utils so usecases can import
  them without reaching into http; share-utils re-exports them for handlers
ratchet: 4 → 1 (only webdav remains)

Verified: typecheck, lint:http, lint:arch, biome clean; 152 new unit + 206
integration tests (redirect/shares/share-public/objects/objects-quota) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): extract webdav resource usecase — http boundary fully locked

The last and largest handler (1273 lines, 63 violations). All WebDAV port
orchestration — auth resolution, path/lock/dead-property access, PROPPATCH, PUT
(streamed reservation + rollback), MKCOL, DELETE, MOVE, recursive COPY, and the
GET download metering — moves into usecases/webdav.ts. The handler keeps the
protocol machinery: XML multistatus rendering, status codes (207/201/204/423/
412/409/416), header parsing (Depth/Destination/Range/If/Lock-Token/Overwrite),
basic-auth/API-key parsing, and all streaming Response framing (FixedLengthStream,
single-range 206, multipart/byteranges). The GET path calls meterDownloadTraffic
directly; getWebDavObjectBody returns the S3 body for the handler to stream,
preserving the exact (storage, object[, range]) call shape and refund-on-failure.

ratchet: 1 → 0. `pnpm lint:http` now reports "http boundary fully locked".

Verified: typecheck, lint:http (LOCKED), lint:arch, biome clean; 43 unit + 41
integration tests (the 2027-line webdav spec) green — behavior preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(webdav): preserve api-key rate-limit message; deterministic object test dates

- resolveWebDavAuth now threads the original ApiKeyRateLimitError message
  through its rate_limited outcome so the 429 body stays "Rate limit exceeded."
  (the webdav auth refactor had hardcoded "Rate limited") — restores
  api-keys-rate-limit.integration.test.ts.
- object.test.ts file() used argless new Date() in both the mock and the
  expected value; a shared FIXED_DATE makes the deep-equal deterministic (it
  flaked under full-suite load).

Full suite green: 4327 passed (184 files).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(e2e): isolate the e2e database — own throwaway DB, wiped each run

entry-node and e2e/global-setup both honor DATABASE_URL, but it defaulted to the
shared dev ./zpan.db and nothing wiped it — so a local `pnpm e2e` ran against
(and mutated) the dev database and wasn't clean between runs. playwright.config
now defaults DATABASE_URL to a throwaway .e2e/e2e.db (node runtime; CF uses D1)
and wipes it on every run, and sets reuseExistingServer:false so e2e never
silently reuses a running dev server. CI is unaffected (fresh box; reuse already
off). Opt out by setting DATABASE_URL yourself.

Verified: `pnpm e2e auth.spec.ts` (7 passed) ran on .e2e/e2e.db while ./zpan.db
stayed byte-for-byte unchanged (mtime+size identical).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 00:47:55 -04:00
2026-06-01 11:05:56 -04:00
2026-04-14 23:44:12 -04:00
2026-05-06 12:41:02 -04:00

ZPan logo

ZPan

Open-source file hosting for your S3-compatible storage.

Deploy on Cloudflare Workers or Docker. Upload directly to object storage.

CI codecov Release GitHub Release Docker Image License

English · 简体中文 · 日本語 · 한국어 · Русский · Español · Português (BR)

🎉 ZPan v2 is here. To celebrate, we're giving away ZPan Pro three ways — for early stargazers, contributors, and new stars. See v2 Launch Offers.

What is ZPan?

ZPan is a lightweight file hosting platform built on top of S3-compatible storage. Files upload directly from the client to S3 through presigned URLs, bypassing server bandwidth entirely. The server is the control plane: auth, metadata, shares, quotas, teams, WebDAV, tool integrations, and admin operations.

The product boundary is intentional: ZPan is a purpose-built S3-backed web drive, not a wrapper around every consumer cloud drive and not a full groupware suite. You bring an S3-compatible bucket; ZPan gives it a clean web UI, public sharing, image-hosting APIs, and deployment options that do not require a VPS or NAS.

Core scenarios:

  • S3 web drive — Manage files, folders, previews, trash, quotas, and team workspaces on top of your own object storage
  • Image hosting — Upload via PicGo, PicList, uPic, ShareX, Flameshot, or API and get a stable URL instantly
  • File sharing — Publish share links with password, expiration, download limits, direct links, and save-to-drive flows
  • Personal homepage — Give each user a public /u/username page for curated shared files and folder-style browsing
  • External access — Mount files through WebDAV and run downloader workers for remote-download workflows

Why ZPan?

S3 only, by design. ZPan does not chase every net-disk provider or build a cloud-drive nesting layer. The storage contract stays simple and durable: S3-compatible buckets such as Cloudflare R2, AWS S3, Backblaze B2, MinIO, RustFS, Tigris, and other S3-compatible services.

Cloudflare Workers first. ZPan is built around Cloudflare Workers, D1, Hono, and web-standard APIs, with Docker and other runtimes as additional deployment targets. You can run a real file-hosting control plane without owning a VPS, keeping a NAS online, or proxying uploads through a long-running server.

Direct transfer path. Uploads and downloads use presigned object-storage URLs whenever possible. That keeps server bandwidth low, avoids a central file-transfer bottleneck, and lets object storage do the heavy lifting.

Practical file workflows. ZPan includes a web file manager, public sharing, image-hosting configuration, API keys, WebDAV access, teams, quotas, remote-download tasks, file previews, and admin controls without turning into a provider-aggregation platform.

Deployable downloader workers. Remote download does not have to run inside the main ZPan instance. You can deploy the downloader together with ZPan for a simple setup, or run it separately in an environment with better network access and fewer source-site restrictions, then let ZPan import the completed files into object storage.

Product Boundaries

ZPan is a good fit when you want:

  • A focused S3-backed web drive instead of a storage-provider zoo
  • A self-hosted image bed and file-sharing app backed by your own bucket
  • Cloudflare-native deployment without maintaining a VPS or NAS
  • Browser-to-S3 transfers instead of app-server file proxying
  • Tool integrations for screenshot, publishing, WebDAV, remote download, and API-driven workflows

ZPan is not trying to be:

  • A real-time document co-editing suite like Nextcloud Office
  • A general-purpose cloud-drive aggregator like AList
  • A local server directory browser like File Browser

How ZPan Compares

Most self-hosted file projects start from either server files, desktop sync, collaboration, or many-provider aggregation. ZPan starts from S3-compatible object storage and a Cloudflare Workers-friendly control plane.

Capability ZPan Cloudreve AList Nextcloud Seafile File Browser
S3-backed product focus ✅ ❌ ❌ ❌ ❌ ❌
S3-compatible storage backend ✅ ✅ ✅ ✅ ⚠️ ❌
Direct browser-to-object-storage path ✅ ⚠️ ⚠️ ❌ ❌ ❌
Cloudflare Workers deployment ✅ ❌ ❌ ❌ ❌ ❌
No VPS/NAS required ✅ ❌ ❌ ❌ ❌ ❌
PicGo/ShareX image-hosting workflow ✅ ❌ ❌ ❌ ❌ ❌
Per-user public file homepage ✅ ⚠️ ⚠️ ⚠️ ⚠️ ❌
Remote download workflow ✅ ✅ ✅ ❌ ❌ ❌
Separately deployable downloader/node ✅ ✅ ⚠️ ❌ ❌ ❌
Multi net-disk aggregation ❌ ❌ ✅ ⚠️ ❌ ❌
Server local directory as primary file root ❌ ⚠️ ⚠️ ⚠️ ❌ ✅
Real-time document co-editing ❌ ❌ ❌ ✅ ⚠️ ❌
Dedicated sync clients Planned ❌ ❌ ✅ ✅ ❌
Team/workspace model ✅ ⚠️ ❌ ✅ ✅ ❌
WebDAV access ✅ ✅ ✅ ✅ ✅ ❌
Share links ✅ ✅ ✅ ✅ ✅ ✅
Docker deployment ✅ ✅ ✅ ✅ ✅ ✅

Legend: ✅ first-class or core capability; ⚠️ partial, edition-dependent, or not the product's main focus; ❌ not a core capability.

Deploy

Deploy via GitHub Actions with zero server management. Free tier covers personal use.

  1. Fork this repository
  2. In your fork, go to Settings → Secrets and variables → Actions and add:
    • CLOUDFLARE_ACCOUNT_ID — found on the Cloudflare dashboard sidebar
    • CLOUDFLARE_API_TOKEN — create one here with Workers Scripts:Edit, D1:Edit, and R2 Storage:Edit permissions (R2 scope is needed to auto-provision the avatar/logo bucket)
  3. Go to the Actions tab, select Deploy to Cloudflare Workers, and click Run workflow

After initial setup, the workflow runs automatically every time you sync your fork with the latest release.

AWS Lambda

Deploy via GitHub Actions using SAM. Lambda Function URL provides HTTPS with no API Gateway needed.

  1. Fork this repository
  2. In your fork, go to Settings → Secrets and variables → Actions and add:
    • TURSO_DATABASE_URL and TURSO_AUTH_TOKEN — from Turso (free, no credit card)
    • AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION
  3. Go to the Actions tab, select Deploy to AWS Lambda, and click Run workflow

See docs/deploy/aws-lambda.md for full setup instructions and IAM permissions.

Docker

Quick start — pull the pre-built image and bring your own S3 storage:

curl -O https://raw.githubusercontent.com/saltbo/zpan/main/deploy/docker-compose.yml
docker compose up -d

With RustFS (self-hosted S3-compatible storage, no external dependencies):

curl -O https://raw.githubusercontent.com/saltbo/zpan/main/deploy/docker-compose.rustfs.yml
docker compose -f docker-compose.rustfs.yml up -d

After startup:

  1. Open the RustFS console at http://localhost:9001 (admin / admin123) and create a bucket (e.g. zpan-bucket)
  2. Open ZPan at http://localhost:8222, register a user (first user gets admin role)
  3. Go to Admin → Storage and add the RustFS storage:
    • Endpoint: http://localhost:9000 (must be reachable from your browser, not the Docker internal hostname)
    • Bucket: the bucket name you created in step 1
    • Region: us-east-1
    • Access Key / Secret Key: admin / admin123

Important: The storage endpoint must be accessible from the client browser, since files upload directly to S3 via presigned URLs. Use http://localhost:9000 for local development, or your server's public URL for production.

Documentation

v1

Looking for ZPan v1 (Go version)? See the v1 branch.

Contributing

See CONTRIBUTING.md for details.

Thank you to all the people who contributed to ZPan!

License

ZPan is under the GNU Affero General Public License v3.0. See the LICENSE file for details.

S
Description
A self-hosted cloud disk base on the cloud storage./ 一个基于云存储的网盘系统,用于自建私人网盘或企业网盘。
Readme AGPL-3.0
70 MiB
Languages
TypeScript 92.7%
Go 6.5%
JavaScript 0.5%
CSS 0.1%