commit 334bb756726da1f8861febec5bd47d73bb15d4cf Author: OpenClaw Agent Date: Fri Mar 20 16:35:51 2026 +0800 feat: initial cfdav project with webdav+r2+d1 and pages admin docs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4bdfa4d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +.dist +.wrangler +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..cbe9e43 --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# cfdav + +A minimal WebDAV → Cloudflare R2 service built on Cloudflare Workers + Hono, with D1 metadata and a small admin UI (Cloudflare Pages). + +## ✅ Project Purpose +- Provide a **lightweight WebDAV gateway** backed by Cloudflare R2 +- Support **multi-user isolation** (each user sees only their own file tree) +- Offer a **simple admin UI** to manage users (add/delete) + +--- + +## ✅ Features +- WebDAV endpoints: OPTIONS, PROPFIND, GET, HEAD, PUT, MKCOL, DELETE, MOVE, COPY, LOCK, UNLOCK, PROPPATCH +- Basic Auth (multi-user in D1; bootstrap admin supported) +- Metadata stored in D1 (SQLite) +- File content stored in R2 (binding: FILES) +- Windows WebDAV compatibility headers (DAV + MS-Author-Via) +- Admin UI (Cloudflare Pages) for user management + +--- + +## ✅ Deployment Flow (Production) + +### 1) Install +```bash +pnpm install +``` + +### 2) Configure `wrangler.toml` +Edit `wrangler.toml` and fill real IDs: +```toml +[[d1_databases]] +binding = "DB" +database_name = "cfdav-db" +database_id = "YOUR_D1_DATABASE_ID" + +[[r2_buckets]] +binding = "FILES" +bucket_name = "YOUR_R2_BUCKET" + +[vars] +ENVIRONMENT = "production" +BASIC_USER = "YOUR_BOOTSTRAP_EMAIL" +BASIC_PASS = "YOUR_BOOTSTRAP_PASSWORD" +``` + +### 3) Create D1 + apply migrations +```bash +wrangler d1 create cfdav-db +wrangler d1 migrations apply cfdav-db --remote +``` + +### 4) Create R2 bucket +```bash +wrangler r2 bucket create +``` + +### 5) Deploy Worker +```bash +wrangler deploy +``` + +--- + +## ✅ Admin UI (Cloudflare Pages) + +### 1) Create Pages Project +```bash +wrangler pages project create cfdav-admin --production-branch main +``` + +### 2) Deploy static UI +```bash +cd web +wrangler pages deploy . --project-name cfdav-admin +``` + +### 3) Login +- API Base: `https://` +- Email/Password: your admin account + +--- + +## ✅ User Management + +### Bootstrap admin +If `users` table is empty, first login with: +- `BASIC_USER` +- `BASIC_PASS` + +This account will be auto-created as **admin**. + +### Create user via UI +Open the Pages admin UI and create users from the form. + +### Create user via CLI (optional) +```bash +node tools/hash.mjs +wrangler d1 execute cfdav-db --remote --command \ +"INSERT INTO users (id,email,password_hash,is_admin,created_at) VALUES ('','user@example.com','',0,'')" +``` + +--- + +## ✅ WebDAV Usage + +### Endpoint +``` +https:///dav/ +``` + +### Example (curl) +```bash +# upload +curl -u user@example.com:password -T ./file.txt https:///dav/file.txt + +# list +curl -u user@example.com:password -X PROPFIND -H 'Depth: 1' https:///dav/ + +# download +curl -u user@example.com:password https:///dav/file.txt +``` + +### Windows Mount (Explorer) +- 右键“此电脑” → “映射网络驱动器” → 地址: + `https:///dav/` +- 账号:邮箱 +- 密码:对应密码 + +### macOS Finder +- Finder → “前往” → “连接服务器” +- 输入:`https:///dav/` + +--- + +## ✅ Required Config Parameters +- **Cloudflare API Token** (Workers + D1 + R2 + Pages权限) +- **CLOUDFLARE_ACCOUNT_ID** +- `D1 database_id` +- `R2 bucket_name` +- `BASIC_USER` / `BASIC_PASS` + +--- + +## Notes +- WebDAV endpoint: `/dav` +- Admin API: `/api/admin/users` +- R2 binding: `FILES` +- D1 binding: `DB` + diff --git a/README.windows.md b/README.windows.md new file mode 100644 index 0000000..9319283 --- /dev/null +++ b/README.windows.md @@ -0,0 +1,24 @@ +# Windows WebDAV Mount Guide (cfdav) + +## 1) 先确认站点 +- WebDAV 地址:`https:///dav/` +- Basic Auth:邮箱 + 密码 + +## 2) Windows 资源管理器挂载 +1. 打开“此电脑” +2. 顶部菜单 → “映射网络驱动器” +3. 地址输入: + `https:///dav/` +4. 使用其他凭据 → 输入账号/密码 + +## 3) 常见问题 +- **提示“文件夹无效”**: + - 你的服务必须在 401 响应上携带 `DAV` 头 + - 必须支持 `LOCK/UNLOCK` +- **无法列目录**: + - 确认 `PROPFIND` 返回 207,并根节点 `` 与请求路径完全一致 + +## 4) 测试命令 +```bash +curl -u user@example.com:password -X PROPFIND -H 'Depth: 1' https:///dav/ +``` diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..f9be44a --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,20 @@ +-- initial schema (files) +CREATE TABLE IF NOT EXISTS files ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + parent_id TEXT, + path TEXT NOT NULL, + name TEXT NOT NULL, + is_folder INTEGER NOT NULL DEFAULT 0, + size INTEGER NOT NULL DEFAULT 0, + mime_type TEXT, + r2_key TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_files_path ON files(path); +CREATE INDEX IF NOT EXISTS idx_files_parent ON files(parent_id); +CREATE INDEX IF NOT EXISTS idx_files_deleted ON files(deleted_at); +CREATE INDEX IF NOT EXISTS idx_files_owner ON files(owner_id); diff --git a/migrations/0002_users.sql b/migrations/0002_users.sql new file mode 100644 index 0000000..f439f4f --- /dev/null +++ b/migrations/0002_users.sql @@ -0,0 +1,9 @@ +-- users table for multi-user auth +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); diff --git a/migrations/0003_admin.sql b/migrations/0003_admin.sql new file mode 100644 index 0000000..b128397 --- /dev/null +++ b/migrations/0003_admin.sql @@ -0,0 +1,2 @@ +-- add admin flag to users +ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0; diff --git a/package.json b/package.json new file mode 100644 index 0000000..f6bbc42 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "cfdav", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "db:migrate": "wrangler d1 migrations apply cfdav-db", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "hono": "^4.6.3" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260311.0", + "wrangler": "^3.78.6", + "typescript": "^5.6.3" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..27c9714 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,985 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + hono: + specifier: ^4.6.3 + version: 4.12.8 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20260311.0 + version: 4.20260317.1 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + wrangler: + specifier: ^3.78.6 + version: 3.114.17(@cloudflare/workers-types@4.20260317.1) + +packages: + + '@cloudflare/kv-asset-handler@0.3.4': + resolution: {integrity: sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==} + engines: {node: '>=16.13'} + + '@cloudflare/unenv-preset@2.0.2': + resolution: {integrity: sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==} + peerDependencies: + unenv: 2.0.0-rc.14 + workerd: ^1.20250124.0 + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20250718.0': + resolution: {integrity: sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20250718.0': + resolution: {integrity: sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20250718.0': + resolution: {integrity: sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20250718.0': + resolution: {integrity: sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20250718.0': + resolution: {integrity: sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@4.20260317.1': + resolution: {integrity: sha512-+G4eVwyCpm8Au1ex8vQBCuA9wnwqetz4tPNRoB/53qvktERWBRMQnrtvC1k584yRE3emMThtuY0gWshvSJ++PQ==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/runtime@1.9.1': + resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + + '@esbuild-plugins/node-globals-polyfill@0.2.3': + resolution: {integrity: sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==} + peerDependencies: + esbuild: '*' + + '@esbuild-plugins/node-modules-polyfill@0.2.2': + resolution: {integrity: sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==} + peerDependencies: + esbuild: '*' + + '@esbuild/android-arm64@0.17.19': + resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.17.19': + resolution: {integrity: sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.17.19': + resolution: {integrity: sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.17.19': + resolution: {integrity: sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.17.19': + resolution: {integrity: sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.17.19': + resolution: {integrity: sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.17.19': + resolution: {integrity: sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.17.19': + resolution: {integrity: sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.17.19': + resolution: {integrity: sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.17.19': + resolution: {integrity: sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.17.19': + resolution: {integrity: sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.17.19': + resolution: {integrity: sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.17.19': + resolution: {integrity: sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.17.19': + resolution: {integrity: sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.17.19': + resolution: {integrity: sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.17.19': + resolution: {integrity: sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.17.19': + resolution: {integrity: sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.17.19': + resolution: {integrity: sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.17.19': + resolution: {integrity: sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.17.19': + resolution: {integrity: sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.17.19': + resolution: {integrity: sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.17.19': + resolution: {integrity: sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + + '@img/sharp-darwin-arm64@0.33.5': + resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.33.5': + resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.0.4': + resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.0.4': + resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.0.4': + resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.0.5': + resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.0.4': + resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.0.4': + resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.33.5': + resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.33.5': + resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.33.5': + resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.33.5': + resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.33.5': + resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.33.5': + resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.33.5': + resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-ia32@0.33.5': + resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.33.5': + resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + acorn-walk@8.3.2: + resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} + engines: {node: '>=0.4.0'} + + acorn@8.14.0: + resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} + engines: {node: '>=0.4.0'} + hasBin: true + + as-table@1.0.55: + resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + data-uri-to-buffer@2.0.2: + resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==} + + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + esbuild@0.17.19: + resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==} + engines: {node: '>=12'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + estree-walker@0.6.1: + resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==} + + exit-hook@2.2.1: + resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} + engines: {node: '>=6'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-source@2.0.12: + resolution: {integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + hono@4.12.8: + resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==} + engines: {node: '>=16.9.0'} + + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + + magic-string@0.25.9: + resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + miniflare@3.20250718.3: + resolution: {integrity: sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==} + engines: {node: '>=16.13'} + hasBin: true + + mustache@4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + printable-characters@1.0.42: + resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==} + + rollup-plugin-inject@3.0.2: + resolution: {integrity: sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==} + deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject. + + rollup-plugin-node-polyfills@0.2.1: + resolution: {integrity: sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==} + + rollup-pluginutils@2.8.2: + resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.33.5: + resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sourcemap-codec@1.4.8: + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + deprecated: Please use @jridgewell/sourcemap-codec instead + + stacktracey@2.2.0: + resolution: {integrity: sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==} + + stoppable@1.1.0: + resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} + engines: {node: '>=4', npm: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + + unenv@2.0.0-rc.14: + resolution: {integrity: sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==} + + workerd@1.20250718.0: + resolution: {integrity: sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==} + engines: {node: '>=16'} + hasBin: true + + wrangler@3.114.17: + resolution: {integrity: sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==} + engines: {node: '>=16.17.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20250408.0 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + youch@3.3.4: + resolution: {integrity: sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==} + + zod@3.22.3: + resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} + +snapshots: + + '@cloudflare/kv-asset-handler@0.3.4': + dependencies: + mime: 3.0.0 + + '@cloudflare/unenv-preset@2.0.2(unenv@2.0.0-rc.14)(workerd@1.20250718.0)': + dependencies: + unenv: 2.0.0-rc.14 + optionalDependencies: + workerd: 1.20250718.0 + + '@cloudflare/workerd-darwin-64@1.20250718.0': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20250718.0': + optional: true + + '@cloudflare/workerd-linux-64@1.20250718.0': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20250718.0': + optional: true + + '@cloudflare/workerd-windows-64@1.20250718.0': + optional: true + + '@cloudflare/workers-types@4.20260317.1': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/runtime@1.9.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild-plugins/node-globals-polyfill@0.2.3(esbuild@0.17.19)': + dependencies: + esbuild: 0.17.19 + + '@esbuild-plugins/node-modules-polyfill@0.2.2(esbuild@0.17.19)': + dependencies: + esbuild: 0.17.19 + escape-string-regexp: 4.0.0 + rollup-plugin-node-polyfills: 0.2.1 + + '@esbuild/android-arm64@0.17.19': + optional: true + + '@esbuild/android-arm@0.17.19': + optional: true + + '@esbuild/android-x64@0.17.19': + optional: true + + '@esbuild/darwin-arm64@0.17.19': + optional: true + + '@esbuild/darwin-x64@0.17.19': + optional: true + + '@esbuild/freebsd-arm64@0.17.19': + optional: true + + '@esbuild/freebsd-x64@0.17.19': + optional: true + + '@esbuild/linux-arm64@0.17.19': + optional: true + + '@esbuild/linux-arm@0.17.19': + optional: true + + '@esbuild/linux-ia32@0.17.19': + optional: true + + '@esbuild/linux-loong64@0.17.19': + optional: true + + '@esbuild/linux-mips64el@0.17.19': + optional: true + + '@esbuild/linux-ppc64@0.17.19': + optional: true + + '@esbuild/linux-riscv64@0.17.19': + optional: true + + '@esbuild/linux-s390x@0.17.19': + optional: true + + '@esbuild/linux-x64@0.17.19': + optional: true + + '@esbuild/netbsd-x64@0.17.19': + optional: true + + '@esbuild/openbsd-x64@0.17.19': + optional: true + + '@esbuild/sunos-x64@0.17.19': + optional: true + + '@esbuild/win32-arm64@0.17.19': + optional: true + + '@esbuild/win32-ia32@0.17.19': + optional: true + + '@esbuild/win32-x64@0.17.19': + optional: true + + '@fastify/busboy@2.1.1': {} + + '@img/sharp-darwin-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 + optional: true + + '@img/sharp-darwin-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.0.5': + optional: true + + '@img/sharp-libvips-linux-s390x@1.0.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + optional: true + + '@img/sharp-linux-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 + optional: true + + '@img/sharp-linux-arm@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 + optional: true + + '@img/sharp-linux-s390x@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.0.4 + optional: true + + '@img/sharp-linux-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + optional: true + + '@img/sharp-wasm32@0.33.5': + dependencies: + '@emnapi/runtime': 1.9.1 + optional: true + + '@img/sharp-win32-ia32@0.33.5': + optional: true + + '@img/sharp-win32-x64@0.33.5': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + acorn-walk@8.3.2: {} + + acorn@8.14.0: {} + + as-table@1.0.55: + dependencies: + printable-characters: 1.0.42 + + blake3-wasm@2.1.5: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + optional: true + + color-name@1.1.4: + optional: true + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + optional: true + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + optional: true + + cookie@0.7.2: {} + + data-uri-to-buffer@2.0.2: {} + + defu@6.1.4: {} + + detect-libc@2.1.2: + optional: true + + esbuild@0.17.19: + optionalDependencies: + '@esbuild/android-arm': 0.17.19 + '@esbuild/android-arm64': 0.17.19 + '@esbuild/android-x64': 0.17.19 + '@esbuild/darwin-arm64': 0.17.19 + '@esbuild/darwin-x64': 0.17.19 + '@esbuild/freebsd-arm64': 0.17.19 + '@esbuild/freebsd-x64': 0.17.19 + '@esbuild/linux-arm': 0.17.19 + '@esbuild/linux-arm64': 0.17.19 + '@esbuild/linux-ia32': 0.17.19 + '@esbuild/linux-loong64': 0.17.19 + '@esbuild/linux-mips64el': 0.17.19 + '@esbuild/linux-ppc64': 0.17.19 + '@esbuild/linux-riscv64': 0.17.19 + '@esbuild/linux-s390x': 0.17.19 + '@esbuild/linux-x64': 0.17.19 + '@esbuild/netbsd-x64': 0.17.19 + '@esbuild/openbsd-x64': 0.17.19 + '@esbuild/sunos-x64': 0.17.19 + '@esbuild/win32-arm64': 0.17.19 + '@esbuild/win32-ia32': 0.17.19 + '@esbuild/win32-x64': 0.17.19 + + escape-string-regexp@4.0.0: {} + + estree-walker@0.6.1: {} + + exit-hook@2.2.1: {} + + exsolve@1.0.8: {} + + fsevents@2.3.3: + optional: true + + get-source@2.0.12: + dependencies: + data-uri-to-buffer: 2.0.2 + source-map: 0.6.1 + + glob-to-regexp@0.4.1: {} + + hono@4.12.8: {} + + is-arrayish@0.3.4: + optional: true + + magic-string@0.25.9: + dependencies: + sourcemap-codec: 1.4.8 + + mime@3.0.0: {} + + miniflare@3.20250718.3: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + acorn: 8.14.0 + acorn-walk: 8.3.2 + exit-hook: 2.2.1 + glob-to-regexp: 0.4.1 + stoppable: 1.1.0 + undici: 5.29.0 + workerd: 1.20250718.0 + ws: 8.18.0 + youch: 3.3.4 + zod: 3.22.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + mustache@4.2.0: {} + + ohash@2.0.11: {} + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + printable-characters@1.0.42: {} + + rollup-plugin-inject@3.0.2: + dependencies: + estree-walker: 0.6.1 + magic-string: 0.25.9 + rollup-pluginutils: 2.8.2 + + rollup-plugin-node-polyfills@0.2.1: + dependencies: + rollup-plugin-inject: 3.0.2 + + rollup-pluginutils@2.8.2: + dependencies: + estree-walker: 0.6.1 + + semver@7.7.4: + optional: true + + sharp@0.33.5: + dependencies: + color: 4.2.3 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-linux-arm': 1.0.5 + '@img/sharp-libvips-linux-arm64': 1.0.4 + '@img/sharp-libvips-linux-s390x': 1.0.4 + '@img/sharp-libvips-linux-x64': 1.0.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-s390x': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-linuxmusl-arm64': 0.33.5 + '@img/sharp-linuxmusl-x64': 0.33.5 + '@img/sharp-wasm32': 0.33.5 + '@img/sharp-win32-ia32': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + optional: true + + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + optional: true + + source-map@0.6.1: {} + + sourcemap-codec@1.4.8: {} + + stacktracey@2.2.0: + dependencies: + as-table: 1.0.55 + get-source: 2.0.12 + + stoppable@1.1.0: {} + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + ufo@1.6.3: {} + + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + + unenv@2.0.0-rc.14: + dependencies: + defu: 6.1.4 + exsolve: 1.0.8 + ohash: 2.0.11 + pathe: 2.0.3 + ufo: 1.6.3 + + workerd@1.20250718.0: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20250718.0 + '@cloudflare/workerd-darwin-arm64': 1.20250718.0 + '@cloudflare/workerd-linux-64': 1.20250718.0 + '@cloudflare/workerd-linux-arm64': 1.20250718.0 + '@cloudflare/workerd-windows-64': 1.20250718.0 + + wrangler@3.114.17(@cloudflare/workers-types@4.20260317.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.3.4 + '@cloudflare/unenv-preset': 2.0.2(unenv@2.0.0-rc.14)(workerd@1.20250718.0) + '@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19) + '@esbuild-plugins/node-modules-polyfill': 0.2.2(esbuild@0.17.19) + blake3-wasm: 2.1.5 + esbuild: 0.17.19 + miniflare: 3.20250718.3 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.14 + workerd: 1.20250718.0 + optionalDependencies: + '@cloudflare/workers-types': 4.20260317.1 + fsevents: 2.3.3 + sharp: 0.33.5 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.18.0: {} + + youch@3.3.4: + dependencies: + cookie: 0.7.2 + mustache: 4.2.0 + stacktracey: 2.2.0 + + zod@3.22.3: {} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ca4235c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,651 @@ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; + +type Env = { + DB: D1Database; + FILES: R2Bucket; + BASIC_USER?: string; + BASIC_PASS?: string; +}; + +type Variables = { + userId: string; + userEmail: string; +}; + +type FileRow = { + id: string; + owner_id: string; + parent_id: string | null; + path: string; + name: string; + is_folder: number; + size: number; + mime_type: string | null; + r2_key: string; + created_at: string; + updated_at: string; + deleted_at: string | null; +}; + +type UserRow = { + id: string; + email: string; + password_hash: string; + is_admin: number; + created_at: string; +}; + +const app = new Hono<{ Bindings: Env; Variables: Variables }>(); + +const DAV_BASE_HEADERS = { + DAV: '1, 2', + 'MS-Author-Via': 'DAV', +}; + +const PBKDF2_ITERATIONS = 100_000; + +app.use( + '*', + cors({ + origin: '*', + allowMethods: [ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'OPTIONS', + 'PROPFIND', + 'PROPPATCH', + 'MKCOL', + 'COPY', + 'MOVE', + 'HEAD', + 'LOCK', + 'UNLOCK', + ], + allowHeaders: ['Content-Type', 'Authorization', 'Depth', 'Destination', 'X-Requested-With', 'If', 'Lock-Token'], + exposeHeaders: ['Content-Length', 'Content-Range', 'ETag', 'DAV', 'Lock-Token'], + maxAge: 86400, + credentials: true, + }) +); + +app.get('/', (c) => c.json({ name: 'cfdav', status: 'ok' })); + +function nowIso() { + return new Date().toISOString(); +} + +function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function normalizePath(path: string) { + if (!path.startsWith('/')) return '/' + path; + return path; +} + +function bytesToHex(bytes: Uint8Array): string { + return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +function hexToBytes(hex: string): Uint8Array { + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +async function pbkdf2Hash(password: string, iterations = PBKDF2_ITERATIONS): Promise { + const enc = new TextEncoder(); + const salt = crypto.getRandomValues(new Uint8Array(16)); + const baseKey = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits']); + const bits = await crypto.subtle.deriveBits( + { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, + baseKey, + 256 + ); + const hash = new Uint8Array(bits); + return `pbkdf2:${iterations}:${bytesToHex(salt)}:${bytesToHex(hash)}`; +} + +async function verifyPassword(password: string, stored: string): Promise { + try { + const [scheme, iterStr, saltHex, hashHex] = stored.split(':'); + if (scheme !== 'pbkdf2') return false; + const iterations = Number(iterStr); + const salt = hexToBytes(saltHex); + const expected = hexToBytes(hashHex); + + const enc = new TextEncoder(); + const baseKey = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits']); + const bits = await crypto.subtle.deriveBits( + { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, + baseKey, + 256 + ); + const actual = new Uint8Array(bits); + if (actual.length !== expected.length) return false; + let diff = 0; + for (let i = 0; i < actual.length; i++) diff |= actual[i] ^ expected[i]; + return diff === 0; + } catch { + return false; + } +} + +function parseBasicAuthHeader(authHeader: string | undefined): { email: string; password: string } | null { + if (!authHeader || !authHeader.startsWith('Basic ')) return null; + try { + const credentials = atob(authHeader.slice(6)); + const idx = credentials.indexOf(':'); + if (idx === -1) return null; + return { + email: credentials.slice(0, idx), + password: credentials.slice(idx + 1), + }; + } catch { + return null; + } +} + +async function ensureBootstrapUser(db: D1Database, env: Env): Promise { + const countRes = await db.prepare('SELECT COUNT(*) as c FROM users').first<{ c: number }>(); + const userCount = Number(countRes?.c || 0); + if (userCount > 0) return; + + const email = env.BASIC_USER || 'demo@example.com'; + const pass = env.BASIC_PASS || 'demo123'; + const hash = await pbkdf2Hash(pass); + await db + .prepare('INSERT INTO users (id, email, password_hash, is_admin, created_at) VALUES (?, ?, ?, 1, ?)') + .bind(crypto.randomUUID(), email, hash, nowIso()) + .run(); +} + +async function unauthorized(): Promise { + return new Response('Unauthorized', { + status: 401, + headers: { + ...DAV_BASE_HEADERS, + 'WWW-Authenticate': 'Basic realm="cfdav"', + }, + }); +} + +async function basicAuth(c: any, next: any) { + const db = c.env.DB as D1Database; + await ensureBootstrapUser(db, c.env as Env); + + const parsed = parseBasicAuthHeader(c.req.header('Authorization')); + if (!parsed) return unauthorized(); + + const user = await db.prepare('SELECT * FROM users WHERE email = ? LIMIT 1').bind(parsed.email).first(); + if (!user) return unauthorized(); + + const ok = await verifyPassword(parsed.password, user.password_hash); + if (!ok) return unauthorized(); + + c.set('userId', user.id); + c.set('userEmail', user.email); + return next(); +} + +async function findFileByPath(db: D1Database, ownerId: string, path: string): Promise { + const p = normalizePath(path); + const row = await db + .prepare('SELECT * FROM files WHERE owner_id = ? AND path = ? AND deleted_at IS NULL LIMIT 1') + .bind(ownerId, p) + .first(); + if (row) return row; + + if (p.endsWith('/')) { + return db + .prepare('SELECT * FROM files WHERE owner_id = ? AND path = ? AND deleted_at IS NULL LIMIT 1') + .bind(ownerId, p.slice(0, -1)) + .first(); + } + + return db + .prepare('SELECT * FROM files WHERE owner_id = ? AND path = ? AND deleted_at IS NULL LIMIT 1') + .bind(ownerId, p + '/') + .first(); +} + +async function listChildren(db: D1Database, ownerId: string, parentId: string | null): Promise { + if (parentId === null) { + const { results } = await db + .prepare('SELECT * FROM files WHERE owner_id = ? AND parent_id IS NULL AND deleted_at IS NULL ORDER BY is_folder DESC, name ASC') + .bind(ownerId) + .all(); + return (results || []) as FileRow[]; + } + + const { results } = await db + .prepare('SELECT * FROM files WHERE owner_id = ? AND parent_id = ? AND deleted_at IS NULL ORDER BY is_folder DESC, name ASC') + .bind(ownerId, parentId) + .all(); + return (results || []) as FileRow[]; +} + +function buildPropfindXML(items: FileRow[], rawPath: string, includeRoot: boolean): string { + const responses: string[] = []; + + if (includeRoot) { + responses.push(` + + ${escapeXml(rawPath)} + + + + + ${new Date().toUTCString()} + ${new Date().toISOString()} + + HTTP/1.1 200 OK + + `); + } + + for (const file of items) { + let logicalPath = file.path; + if (!logicalPath.startsWith('/')) logicalPath = '/' + logicalPath; + if (file.is_folder && !logicalPath.endsWith('/')) logicalPath += '/'; + const href = '/dav' + logicalPath; + + responses.push(` + + ${escapeXml(href)} + + + ${escapeXml(file.name)} + ${file.size} + ${new Date(file.updated_at).toUTCString()} + ${file.created_at} + ${file.is_folder ? '' : ''} + ${file.mime_type || 'application/octet-stream'} + + HTTP/1.1 200 OK + + `); + } + + return `\n${responses.join('')}\n`; +} + +const dav = new Hono<{ Bindings: Env; Variables: Variables }>(); + +dav.options('/*', () => { + return new Response(null, { + status: 200, + headers: { + ...DAV_BASE_HEADERS, + Allow: 'OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, PROPFIND, PROPPATCH, MOVE, COPY, LOCK, UNLOCK', + 'Content-Length': '0', + }, + }); +}); + +dav.use('*', basicAuth); + +dav.all('/*', async (c) => { + const method = c.req.method.toUpperCase(); + const rawPath = new URL(c.req.url).pathname; + const path = rawPath.replace(/^\/dav/, '') || '/'; + + switch (method) { + case 'PROPFIND': + return handlePropfind(c, path, rawPath); + case 'GET': + case 'HEAD': + return handleGet(c, path, method === 'HEAD'); + case 'PUT': + return handlePut(c, path); + case 'MKCOL': + return handleMkcol(c, path); + case 'DELETE': + return handleDelete(c, path); + case 'MOVE': + return handleMove(c, path); + case 'COPY': + return handleCopy(c, path); + case 'LOCK': + return handleLock(rawPath); + case 'UNLOCK': + return new Response(null, { status: 204, headers: DAV_BASE_HEADERS }); + case 'PROPPATCH': + return handleProppatch(rawPath); + default: + return new Response('Method Not Allowed', { status: 405, headers: DAV_BASE_HEADERS }); + } +}); + +async function handlePropfind(c: any, path: string, rawPath: string) { + const ownerId = c.get('userId') as string; + const depth = c.req.header('Depth') || '1'; + const db = c.env.DB as D1Database; + const isRoot = path === '/' || path === ''; + + const xmlHeaders = { + 'Content-Type': 'application/xml; charset=utf-8', + ...DAV_BASE_HEADERS, + }; + + if (depth === '0') { + if (isRoot) { + return new Response(buildPropfindXML([], rawPath, true), { status: 207, headers: xmlHeaders }); + } + const current = await findFileByPath(db, ownerId, path); + return new Response(buildPropfindXML(current ? [current] : [], rawPath, false), { status: 207, headers: xmlHeaders }); + } + + if (isRoot) { + const items = await listChildren(db, ownerId, null); + return new Response(buildPropfindXML(items, rawPath, true), { status: 207, headers: xmlHeaders }); + } + + const parent = await findFileByPath(db, ownerId, path); + if (!parent) return new Response(buildPropfindXML([], rawPath, false), { status: 207, headers: xmlHeaders }); + + const items = await listChildren(db, ownerId, parent.id); + return new Response(buildPropfindXML(items, rawPath, true), { status: 207, headers: xmlHeaders }); +} + +async function handleGet(c: any, path: string, headOnly: boolean) { + const ownerId = c.get('userId') as string; + const db = c.env.DB as D1Database; + + if (path === '/' || path === '') { + return new Response(headOnly ? null : 'Root Collection', { + status: 200, + headers: { ...DAV_BASE_HEADERS, 'Content-Type': 'text/html', 'Content-Length': '14' }, + }); + } + + const file = await findFileByPath(db, ownerId, path); + if (!file) return new Response('Not Found', { status: 404, headers: DAV_BASE_HEADERS }); + if (file.is_folder) return new Response('Is a collection', { status: 400, headers: DAV_BASE_HEADERS }); + + const obj = await c.env.FILES.get(file.r2_key); + if (!obj) return new Response('Not Found', { status: 404, headers: DAV_BASE_HEADERS }); + + return new Response(headOnly ? null : obj.body, { + headers: { + ...DAV_BASE_HEADERS, + 'Content-Type': file.mime_type || 'application/octet-stream', + 'Content-Length': String(file.size), + }, + }); +} + +async function handlePut(c: any, path: string) { + const ownerId = c.get('userId') as string; + const db = c.env.DB as D1Database; + const body = await c.req.arrayBuffer(); + const fileName = path.split('/').pop() || 'untitled'; + const parentPath = path.lastIndexOf('/') > 0 ? path.slice(0, path.lastIndexOf('/')) : '/'; + + let parentId: string | null = null; + if (parentPath !== '/') { + const parent = await findFileByPath(db, ownerId, parentPath); + if (!parent) { + const parts = parentPath.split('/').filter(Boolean); + let currentPath = ''; + let currentParentId: string | null = null; + for (const part of parts) { + currentPath = currentPath ? `${currentPath}/${part}` : `/${part}`; + const folder = await findFileByPath(db, ownerId, currentPath); + if (!folder) { + const folderId = crypto.randomUUID(); + const now = nowIso(); + await db + .prepare( + 'INSERT INTO files (id, owner_id, parent_id, path, name, is_folder, size, mime_type, r2_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 1, 0, NULL, ?, ?, ?)' + ) + .bind(folderId, ownerId, currentParentId, currentPath, part, `folders/${folderId}`, now, now) + .run(); + currentParentId = folderId; + } else { + currentParentId = folder.id; + } + } + parentId = currentParentId; + } else { + parentId = parent.id; + } + } + + const existing = await findFileByPath(db, ownerId, path); + const fileId = existing?.id || crypto.randomUUID(); + const mimeType = c.req.header('Content-Type') || 'application/octet-stream'; + const now = nowIso(); + const r2Key = `files/${ownerId}/${fileId}/${fileName}`; + + await c.env.FILES.put(r2Key, body, { httpMetadata: { contentType: mimeType } }); + + if (existing) { + await db + .prepare('UPDATE files SET size = ?, mime_type = ?, updated_at = ?, r2_key = ? WHERE id = ? AND owner_id = ?') + .bind(body.byteLength, mimeType, now, r2Key, fileId, ownerId) + .run(); + return new Response(null, { status: 204, headers: DAV_BASE_HEADERS }); + } + + await db + .prepare( + 'INSERT INTO files (id, owner_id, parent_id, path, name, is_folder, size, mime_type, r2_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)' + ) + .bind(fileId, ownerId, parentId, path, fileName, body.byteLength, mimeType, r2Key, now, now) + .run(); + + return new Response(null, { status: 201, headers: DAV_BASE_HEADERS }); +} + +async function handleMkcol(c: any, path: string) { + const ownerId = c.get('userId') as string; + const db = c.env.DB as D1Database; + const folderName = path.split('/').pop() || 'untitled'; + const parentPath = path.lastIndexOf('/') > 0 ? path.slice(0, path.lastIndexOf('/')) : '/'; + + let parentId: string | null = null; + if (parentPath !== '/') { + const parent = await findFileByPath(db, ownerId, parentPath); + if (!parent) return new Response('Conflict: parent not found', { status: 409, headers: DAV_BASE_HEADERS }); + parentId = parent.id; + } + + const normalizedPath = path.endsWith('/') ? path : path + '/'; + const existing = await findFileByPath(db, ownerId, normalizedPath); + if (existing) return new Response('Method Not Allowed: already exists', { status: 405, headers: DAV_BASE_HEADERS }); + + const folderId = crypto.randomUUID(); + const now = nowIso(); + await db + .prepare( + 'INSERT INTO files (id, owner_id, parent_id, path, name, is_folder, size, mime_type, r2_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 1, 0, NULL, ?, ?, ?)' + ) + .bind(folderId, ownerId, parentId, normalizedPath, folderName, `folders/${folderId}`, now, now) + .run(); + + return new Response(null, { status: 201, headers: DAV_BASE_HEADERS }); +} + +async function handleDelete(c: any, path: string) { + const ownerId = c.get('userId') as string; + const db = c.env.DB as D1Database; + const file = await findFileByPath(db, ownerId, path); + if (!file) return new Response('Not Found', { status: 404, headers: DAV_BASE_HEADERS }); + + if (!file.is_folder) await c.env.FILES.delete(file.r2_key); + await db.prepare('UPDATE files SET deleted_at = ? WHERE id = ? AND owner_id = ?').bind(nowIso(), file.id, ownerId).run(); + + return new Response(null, { status: 204, headers: DAV_BASE_HEADERS }); +} + +async function handleMove(c: any, path: string) { + const ownerId = c.get('userId') as string; + const destination = c.req.header('Destination'); + if (!destination) return new Response('Destination header required', { status: 400, headers: DAV_BASE_HEADERS }); + const destPath = new URL(destination).pathname.replace(/^\/dav/, '') || '/'; + + const db = c.env.DB as D1Database; + const file = await findFileByPath(db, ownerId, path); + if (!file) return new Response('Not Found', { status: 404, headers: DAV_BASE_HEADERS }); + + const newName = destPath.split('/').pop() || file.name; + const destParentPath = destPath.lastIndexOf('/') > 0 ? destPath.slice(0, destPath.lastIndexOf('/')) : '/'; + let destParentId: string | null = null; + if (destParentPath !== '/') { + const destParent = await findFileByPath(db, ownerId, destParentPath); + destParentId = destParent?.id || null; + } + + await db + .prepare('UPDATE files SET name = ?, path = ?, parent_id = ?, updated_at = ? WHERE id = ? AND owner_id = ?') + .bind(newName, destPath, destParentId, nowIso(), file.id, ownerId) + .run(); + + return new Response(null, { status: 201, headers: DAV_BASE_HEADERS }); +} + +async function handleCopy(c: any, path: string) { + const ownerId = c.get('userId') as string; + const destination = c.req.header('Destination'); + if (!destination) return new Response('Destination header required', { status: 400, headers: DAV_BASE_HEADERS }); + const destPath = new URL(destination).pathname.replace(/^\/dav/, '') || '/'; + + const db = c.env.DB as D1Database; + const file = await findFileByPath(db, ownerId, path); + if (!file) return new Response('Not Found', { status: 404, headers: DAV_BASE_HEADERS }); + + const newName = destPath.split('/').pop() || file.name; + const newId = crypto.randomUUID(); + const now = nowIso(); + + if (!file.is_folder) { + const src = await c.env.FILES.get(file.r2_key); + if (src) { + const newKey = `files/${ownerId}/${newId}/${newName}`; + await c.env.FILES.put(newKey, src.body, { + httpMetadata: { contentType: file.mime_type || 'application/octet-stream' }, + }); + await db + .prepare( + 'INSERT INTO files (id, owner_id, parent_id, path, name, is_folder, size, mime_type, r2_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)' + ) + .bind(newId, ownerId, file.parent_id, destPath, newName, file.size, file.mime_type, newKey, now, now) + .run(); + } + } + + return new Response(null, { status: 201, headers: DAV_BASE_HEADERS }); +} + +function handleLock(rawPath: string) { + const token = `urn:uuid:${crypto.randomUUID()}`; + const xml = ` + + + + + + 0 + + Second-3600 + ${escapeXml(token)} + ${escapeXml(rawPath)} + + +`; + + return new Response(xml, { + status: 200, + headers: { + ...DAV_BASE_HEADERS, + 'Content-Type': 'application/xml; charset=utf-8', + 'Lock-Token': `<${token}>`, + }, + }); +} + +function handleProppatch(rawPath: string) { + const xml = ` + + + ${escapeXml(rawPath)} + + + HTTP/1.1 403 Forbidden + + +`; + + return new Response(xml, { + status: 207, + headers: { + ...DAV_BASE_HEADERS, + 'Content-Type': 'application/xml; charset=utf-8', + }, + }); +} + +// ─────────────────────────────────────────────────────────── +// Admin API +// ─────────────────────────────────────────────────────────── +const api = new Hono<{ Bindings: Env; Variables: Variables }>(); + +api.use('*', basicAuth); + +api.use('*', async (c, next) => { + const db = c.env.DB as D1Database; + const user = await db.prepare('SELECT * FROM users WHERE id = ? LIMIT 1').bind(c.get('userId')).first(); + if (!user || user.is_admin !== 1) { + return c.json({ success: false, error: 'forbidden' }, 403); + } + await next(); +}); + +api.get('/admin/users', async (c) => { + const db = c.env.DB as D1Database; + const { results } = await db.prepare('SELECT id,email,is_admin,created_at FROM users ORDER BY created_at DESC').all(); + return c.json({ success: true, data: results || [] }); +}); + +api.post('/admin/users', async (c) => { + const db = c.env.DB as D1Database; + const body = await c.req.json(); + const email = String(body.email || '').trim(); + const password = String(body.password || ''); + const isAdmin = body.isAdmin ? 1 : 0; + if (!email || !password) return c.json({ success: false, error: 'email/password required' }, 400); + + const existing = await db.prepare('SELECT id FROM users WHERE email = ? LIMIT 1').bind(email).first(); + if (existing) return c.json({ success: false, error: 'user exists' }, 409); + + const hash = await pbkdf2Hash(password); + await db + .prepare('INSERT INTO users (id,email,password_hash,is_admin,created_at) VALUES (?, ?, ?, ?, ?)') + .bind(crypto.randomUUID(), email, hash, isAdmin, nowIso()) + .run(); + return c.json({ success: true }); +}); + +api.delete('/admin/users/:id', async (c) => { + const id = c.req.param('id'); + const db = c.env.DB as D1Database; + await db.prepare('DELETE FROM users WHERE id = ?').bind(id).run(); + return c.json({ success: true }); +}); + +app.route('/api', api); +app.route('/dav', dav); + +export default app; diff --git a/tools/hash.mjs b/tools/hash.mjs new file mode 100644 index 0000000..fa4e7ba --- /dev/null +++ b/tools/hash.mjs @@ -0,0 +1,30 @@ +#!/usr/bin/env node +import { webcrypto } from 'node:crypto'; + +const password = process.argv[2]; +if (!password) { + console.error('Usage: node tools/hash.mjs '); + process.exit(1); +} + +const PBKDF2_ITERATIONS = 100_000; +const SALT_BYTES = 16; + +async function hashPassword(pw) { + const enc = new TextEncoder(); + const salt = webcrypto.getRandomValues(new Uint8Array(SALT_BYTES)); + const baseKey = await webcrypto.subtle.importKey('raw', enc.encode(pw), 'PBKDF2', false, ['deriveBits']); + const bits = await webcrypto.subtle.deriveBits( + { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, + baseKey, + 256 + ); + const hashArr = new Uint8Array(bits); + const saltHex = [...salt].map((b) => b.toString(16).padStart(2, '0')).join(''); + const hashHex = [...hashArr].map((b) => b.toString(16).padStart(2, '0')).join(''); + return `pbkdf2:${PBKDF2_ITERATIONS}:${saltHex}:${hashHex}`; +} + +hashPassword(password).then((h) => { + console.log(h); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d47bc06 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "WebWorker"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["@cloudflare/workers-types"] + }, + "include": ["src/**/*.ts"] +} diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..8aa2ba9 --- /dev/null +++ b/web/app.js @@ -0,0 +1,117 @@ +const $ = (id) => document.getElementById(id); + +function log(msg) { + const el = $('log'); + el.textContent = `[${new Date().toISOString()}] ${msg}\n` + el.textContent; +} + +function getAuthHeader() { + const email = $('email').value.trim(); + const pass = $('password').value; + const token = btoa(`${email}:${pass}`); + return `Basic ${token}`; +} + +function apiBase() { + const base = $('apiBase').value.trim(); + return base ? base.replace(/\/$/, '') : ''; +} + +async function apiFetch(path, options = {}) { + const url = apiBase() + path; + const headers = options.headers || {}; + headers['Authorization'] = getAuthHeader(); + headers['Content-Type'] = 'application/json'; + const res = await fetch(url, { ...options, headers }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`${res.status} ${res.statusText}: ${text}`); + } + return res.json(); +} + +async function loadUsers() { + const data = await apiFetch('/api/admin/users'); + const list = data.data || []; + const tbody = $('userList'); + tbody.innerHTML = ''; + list.forEach((u) => { + const tr = document.createElement('tr'); + tr.innerHTML = ` + ${u.email} + ${u.is_admin ? 'yes' : 'no'} + ${u.created_at} + + `; + tr.querySelector('button').addEventListener('click', () => deleteUser(u.id)); + tbody.appendChild(tr); + }); + log('Loaded users'); +} + +async function createUser() { + const email = $('newEmail').value.trim(); + const password = $('newPassword').value; + const isAdmin = $('newIsAdmin').checked; + if (!email || !password) { + log('Email and password required'); + return; + } + await apiFetch('/api/admin/users', { + method: 'POST', + body: JSON.stringify({ email, password, isAdmin }) + }); + $('newEmail').value = ''; + $('newPassword').value = ''; + $('newIsAdmin').checked = false; + log('User created'); + await loadUsers(); +} + +async function deleteUser(id) { + if (!confirm('Delete this user?')) return; + await apiFetch(`/api/admin/users/${id}`, { method: 'DELETE' }); + log('User deleted'); + await loadUsers(); +} + +function saveSettings() { + localStorage.setItem('cfdav_api_base', $('apiBase').value.trim()); + localStorage.setItem('cfdav_email', $('email').value.trim()); +} + +function loadSettings() { + $('apiBase').value = localStorage.getItem('cfdav_api_base') || ''; + $('email').value = localStorage.getItem('cfdav_email') || ''; +} + +function setLoggedIn(state) { + $('loginCard').classList.toggle('hidden', state); + $('app').classList.toggle('hidden', !state); +} + +async function login() { + try { + saveSettings(); + await loadUsers(); + setLoggedIn(true); + log('Login success'); + } catch (e) { + setLoggedIn(false); + log(`Login failed: ${e.message}`); + } +} + +function logout() { + $('password').value = ''; + setLoggedIn(false); + log('Logged out'); +} + +$('loginBtn').addEventListener('click', login); +$('refreshBtn').addEventListener('click', () => loadUsers().catch((e) => log(e.message))); +$('createBtn').addEventListener('click', () => createUser().catch((e) => log(e.message))); +$('logoutBtn').addEventListener('click', logout); + +loadSettings(); +setLoggedIn(false); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..3396060 --- /dev/null +++ b/web/index.html @@ -0,0 +1,77 @@ + + + + + + cfdav Admin + + + +
+

cfdav Admin

+ +
+

Login

+
+ + +
+
+ + +
+
+ + +
+ +

提示:API Base 留空则默认同域(/api/admin)。

+
+ + + +
+

Log

+

+      
+
+ + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..9365571 --- /dev/null +++ b/web/style.css @@ -0,0 +1 @@ +*{box-sizing:border-box;font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif}body{margin:0;background:#0b0f14;color:#e6edf3}.container{max-width:960px;margin:40px auto;padding:0 16px}.card{background:#111827;border:1px solid #1f2937;border-radius:12px;padding:16px;margin-bottom:16px}h1,h2{margin:0 0 12px}label{display:block;margin-bottom:6px;color:#9ca3af}.row{margin-bottom:12px}input{width:100%;padding:8px;border-radius:8px;border:1px solid #374151;background:#0f172a;color:#e6edf3}button{padding:8px 14px;border:0;border-radius:8px;background:#2563eb;color:#fff;cursor:pointer}button:hover{background:#1d4ed8}.ghost{background:#374151}.ghost:hover{background:#4b5563}.toolbar{margin-bottom:8px;display:flex;gap:8px;align-items:center}table{width:100%;border-collapse:collapse}th,td{border-bottom:1px solid #1f2937;padding:8px;text-align:left}.hint{color:#9ca3af;font-size:12px}pre{background:#0f172a;border:1px solid #1f2937;border-radius:8px;padding:10px;white-space:pre-wrap}.hidden{display:none} diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..ad2d3c9 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,21 @@ +name = "cfdav" +main = "src/index.ts" +compatibility_date = "2024-01-01" +compatibility_flags = ["nodejs_compat"] + +[[d1_databases]] +binding = "DB" +database_name = "cfdav-db" +database_id = "YOUR_D1_DATABASE_ID" + +[[r2_buckets]] +binding = "FILES" +bucket_name = "YOUR_R2_BUCKET" + +[vars] +ENVIRONMENT = "production" +BASIC_USER = "YOUR_BOOTSTRAP_EMAIL" +BASIC_PASS = "YOUR_BOOTSTRAP_PASSWORD" + +[triggers] +crons = []