From 57aa7457ae5bb5af5b8b59f2298445067ac52276 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Fri, 6 Mar 2026 01:00:19 +0800 Subject: [PATCH] feat: add support for KV storage mode and enhance attachment handling --- README.md | 32 +++++++--- README_EN.md | 30 ++++++--- package.json | 7 +- src/config/limits.ts | 2 +- src/handlers/admin.ts | 5 +- src/handlers/attachments.ts | 66 ++++++++++--------- src/handlers/sends.ts | 46 ++++++++----- src/services/blob-store.ts | 124 ++++++++++++++++++++++++++++++++++++ src/services/ratelimit.ts | 28 ++++++++ src/types/index.ts | 5 +- wrangler.kv.toml | 15 +++++ 11 files changed, 289 insertions(+), 71 deletions(-) create mode 100644 src/services/blob-store.ts create mode 100644 wrangler.kv.toml diff --git a/README.md b/README.md index 6e2327f..3f4772c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ [![Powered by Cloudflare](https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white)](https://workers.cloudflare.com/) [![License: LGPL-3.0](https://img.shields.io/badge/License-LGPL--3.0-2ea44f)](./LICENSE) -[![Deploy to Cloudflare Workers](https://img.shields.io/badge/Deploy%20to-Cloudflare%20Workers-F38020?logo=cloudflare&logoColor=white)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/NodeWarden) +[![Deploy (R2)](https://img.shields.io/badge/Deploy%20(R2)-Cloudflare%20Workers-F38020?logo=cloudflare&logoColor=white)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/NodeWarden) +[![Deploy (KV)](https://img.shields.io/badge/Deploy%20(KV)-Cloudflare%20Workers-2ea44f?logo=cloudflare&logoColor=white)](./README_EN.md#kv-mode-no-credit-card) [![Latest Release](https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag)](https://github.com/shuaiplus/NodeWarden/releases/latest) [![Sync Upstream](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg)](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml) @@ -29,7 +30,7 @@ English:[`README_EN.md`](./README_EN.md) | Web Vault(登录/笔记/卡片/身份) | ✅ | ✅ | 网页端密码库管理页面 | | 文件夹 / 收藏 | ✅ | ✅ | 常用管理能力可用 | | 全量同步 `/api/sync` | ✅ | ✅ | 已做兼容与性能优化 | -| 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 | +| 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2(或可选 KV 模式) | | 导入导出功能 | ✅ | ✅ | 完整实现,含 Bitwarden 密码库+附件 ZIP 导入 | | 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` | | passkey、TOTP字段 | ❌ | ✅ |官方需要会员,我们的不需要 | @@ -58,14 +59,22 @@ English:[`README_EN.md`](./README_EN.md) **部署步骤:** 1. 首先Fork本仓库,命名为**NodeWarden** -2. 点击下面的一键部署按钮,修改项目名称为**NodeWarden2**,修改**JWT_SECRET**成32为随机字符串 -3. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden) -4. 部署完成后,同一页面打开workers设置,将**Git存储库**断开连接 -5. 同一位置,**Git存储库**链接至第一步Fork的仓库 +2. 点击下面的一键部署按钮,修改项目名称为**NodeWarden2**,修改**JWT_SECRET**成32为随机字符串; +**若无信用卡,储存库可选KV模式**,一键部署页面里部署命令改成:npm run deploy:kv -**同步上游(更新):** -- 手动:Github打开你Fork的私人仓库,看到顶部同步提示时,点击 “Sync fork”。 -- 自动:进入你的 Fork 仓库 → Actions,点击 “I understand my workflows, go ahead and enable them”,每天凌晨三点自动同步至上游 + [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden) + +3. 部署完成后,同一页面打开workers设置,将**Git存储库**断开连接 +4. 同一位置,**Git存储库**链接至第一步Fork的仓库 + +> [!NOTE] R2 vs KV +>- R2:需绑定银行卡;**单个附件/Send上限 100MB**(代码限制,可自行修改);**总量 10GB 免费** +>- KV:无需绑卡;**单个附件/Send 文件上限 25 MiB**(cloudflare限制,不可修改);**总量 1GB 免费** + + +> [!TIP] 同步上游(更新仓库): +>- 手动:Github打开你Fork的私人仓库,看到顶部同步提示时,点击 “Sync fork”。 +>- 自动:进入你的 Fork 仓库 → Actions,点击 “I understand my workflows, go ahead and enable them”,每天凌晨三点自动同步至上游 ### CLI 部署 @@ -87,6 +96,11 @@ npx wrangler r2 bucket create nodewarden-attachments # 部署 npm run deploy +# (可选)KV 模式(无 R2 / 无信用卡) +npx wrangler kv namespace create ATTACHMENTS_KV +# 将返回的 namespace id 填入 wrangler.kv.toml 的 [[kv_namespaces]].id +npm run deploy:kv + # 需更新时重新拉取仓库,重新部署即可,无需创建云资源 git clone https://github.com/shuaiplus/NodeWarden.git cd NodeWarden diff --git a/README_EN.md b/README_EN.md index 1a5714c..2e3bec7 100644 --- a/README_EN.md +++ b/README_EN.md @@ -8,7 +8,8 @@ [![Powered by Cloudflare](https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white)](https://workers.cloudflare.com/) [![License: LGPL-3.0](https://img.shields.io/badge/License-LGPL--3.0-2ea44f)](./LICENSE) -[![Deploy to Cloudflare Workers](https://img.shields.io/badge/Deploy%20to-Cloudflare%20Workers-F38020?logo=cloudflare&logoColor=white)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/NodeWarden) +[![Deploy (R2)](https://img.shields.io/badge/Deploy%20(R2)-Cloudflare%20Workers-F38020?logo=cloudflare&logoColor=white)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/NodeWarden) +[![Deploy (KV)](https://img.shields.io/badge/Deploy%20(KV)-Cloudflare%20Workers-2ea44f?logo=cloudflare&logoColor=white)](#kv-mode-no-credit-card) [![Latest Release](https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag)](https://github.com/shuaiplus/NodeWarden/releases/latest) [![Sync Upstream](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg)](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml) @@ -29,7 +30,7 @@ | Web Vault (logins/notes/cards/identities) | ✅ | ✅ | Web-based vault management UI | | Folders / Favorites | ✅ | ✅ | Common vault organization supported | | Full sync `/api/sync` | ✅ | ✅ | Compatibility and performance optimized | -| Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 | +| Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 (or optional KV mode) | | mport / export | ✅ | ✅ | Fully implemented, including Bitwarden vault + attachments ZIP import. | | Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` | | passkey、TOTP fields | ❌ | ✅ | Official service requires premium; NodeWarden does not | @@ -59,14 +60,20 @@ **Deploy steps:** 1. Fork this repository and name it **NodeWarden**. -2. Click the deploy button below, rename the project to **NodeWarden2**, and set **JWT_SECRET** to a 32-character random string. -3. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden) -4. After deployment, open the Workers settings on the same page and disconnect the **Git repository**. -5. From the same location, reconnect the **Git repository** to the fork you created in step 1. +2. Click the one-click deploy button below, rename the project to **NodeWarden2**, set **JWT_SECRET** to a 32-character random string; if you **do not have a credit card**, **use KV mode** and change the deploy command to `npm run deploy:kv`. -**Sync upstream (update):** -- Manual: Open your forked repository on GitHub and click **Sync fork** when the sync prompt appears at the top. -- Automatic: Go to your fork → Actions, click "I understand my workflows, go ahead and enable them". The repository will auto-sync with upstream every day at 3 AM. + [![Deploy to Cloudflare Workers (R2)](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden) + +3. After deployment, open the Workers settings on the same page and disconnect the **Git repository**. +4. From the same location, reconnect the **Git repository** to the fork you created in step 1. + +> [!NOTE] R2 vs KV +>- R2: typically requires a payment method; **single attachment/Send file limit is 100 MB** (**project-level limit, editable in code**); **10 GB free storage included**. +>- KV: no card required; **single attachment/Send file limit is 25 MiB** (**Cloudflare platform limit, not editable**); **1 GB free storage included**. + +> [!TIP] Sync upstream (keep your fork updated): +>- Manual: open your fork on GitHub and click **Sync fork** when prompted. +>- Automatic: in your fork, go to **Actions**, click "I understand my workflows, go ahead and enable them". It will auto-sync from upstream daily at 3 AM. ### CLI deploy @@ -88,6 +95,11 @@ npx wrangler r2 bucket create nodewarden-attachments # Deploy npm run deploy +# (Optional) KV mode (no R2 / no credit card) +npx wrangler kv namespace create ATTACHMENTS_KV +# Put returned namespace id into wrangler.kv.toml -> [[kv_namespaces]].id +npm run deploy:kv + # To update later: re-clone and re-deploy — no need to recreate cloud resources git clone https://github.com/shuaiplus/NodeWarden.git cd NodeWarden diff --git a/package.json b/package.json index 91ff466..ade3ea5 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,10 @@ "type": "module", "scripts": { "dev": "wrangler dev -c wrangler.toml", + "dev:kv": "wrangler dev -c wrangler.kv.toml", "build": "vite build --config webapp/vite.config.ts", - "deploy": "wrangler deploy" + "deploy": "wrangler deploy", + "deploy:kv": "wrangler deploy -c wrangler.kv.toml" }, "keywords": [ "bitwarden", @@ -28,6 +30,9 @@ }, "ATTACHMENTS": { "description": "R2 bucket for storing file attachments" + }, + "ATTACHMENTS_KV": { + "description": "Optional KV namespace fallback for attachment/send-file storage" } } }, diff --git a/src/config/limits.ts b/src/config/limits.ts index 01a50c2..d6dc4db 100644 --- a/src/config/limits.ts +++ b/src/config/limits.ts @@ -82,7 +82,7 @@ send: { // Max file size allowed for Send file uploads. // Send 文件上传大小上限。 - maxFileSizeBytes: 550_502_400, + maxFileSizeBytes: 100 * 1024 * 1024, // Max days allowed between now and deletion date. // 允许的最远删除日期(距当前天数)。 maxDeletionDays: 31, diff --git a/src/handlers/admin.ts b/src/handlers/admin.ts index 68d2d69..a2b1ab1 100644 --- a/src/handlers/admin.ts +++ b/src/handlers/admin.ts @@ -2,6 +2,7 @@ import { Env, User, Invite } from '../types'; import { StorageService } from '../services/storage'; import { jsonResponse, errorResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; +import { deleteBlobObject, getAttachmentObjectKey, getSendFileObjectKey } from '../services/blob-store'; function isAdmin(user: User): boolean { return user.role === 'admin' && user.status === 'active'; @@ -260,7 +261,7 @@ export async function handleAdminDeleteUser( const attachmentMap = await storage.getAttachmentsByUserId(target.id); for (const [cipherId, attachments] of attachmentMap) { for (const att of attachments) { - await env.ATTACHMENTS.delete(`${cipherId}/${att.id}`); + await deleteBlobObject(env, getAttachmentObjectKey(cipherId, att.id)); } } // 2. Send files (keyed by sends/sendId/fileId) @@ -271,7 +272,7 @@ export async function handleAdminDeleteUser( const parsed = JSON.parse(send.data) as Record; const fileId = typeof parsed.id === 'string' ? parsed.id : null; if (fileId) { - await env.ATTACHMENTS.delete(`sends/${send.id}/${fileId}`); + await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId)); } } catch { /* non-file send or bad data, skip */ } } diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index f3127b1..276da2a 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -5,6 +5,13 @@ import { generateUUID } from '../utils/uuid'; import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt'; import { cipherToResponse } from './ciphers'; import { LIMITS } from '../config/limits'; +import { + deleteBlobObject, + getAttachmentObjectKey, + getBlobObject, + getBlobStorageMaxBytes, + putBlobObject, +} from '../services/blob-store'; // Format file size to human readable function formatSize(bytes: number): string { @@ -14,11 +21,6 @@ function formatSize(bytes: number): string { return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } -// Get R2 object path for attachment -function getAttachmentPath(cipherId: string, attachmentId: string): string { - return `${cipherId}/${attachmentId}`; -} - // POST /api/ciphers/{cipherId}/attachment/v2 // Creates attachment metadata and returns upload URL export async function handleCreateAttachment( @@ -86,9 +88,6 @@ export async function handleCreateAttachment( }); } -// Maximum file size: 100MB -const MAX_FILE_SIZE = LIMITS.attachment.maxFileSizeBytes; - // POST /api/ciphers/{cipherId}/attachment/{attachmentId} // Upload attachment file content export async function handleUploadAttachment( @@ -99,6 +98,7 @@ export async function handleUploadAttachment( attachmentId: string ): Promise { const storage = new StorageService(env.DB); + const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.attachment.maxFileSizeBytes); // Verify cipher exists and belongs to user const cipher = await storage.getCipher(cipherId); @@ -114,8 +114,8 @@ export async function handleUploadAttachment( // Check content-length header for size limit const contentLength = request.headers.get('content-length'); - if (contentLength && parseInt(contentLength) > MAX_FILE_SIZE) { - return errorResponse('File too large. Maximum size is 100MB', 413); + if (contentLength && parseInt(contentLength) > maxFileSize) { + return errorResponse(`File too large. Maximum size is ${Math.floor(maxFileSize / (1024 * 1024))}MB`, 413); } // Get the file from multipart form data @@ -132,21 +132,27 @@ export async function handleUploadAttachment( } // Check actual file size - if (file.size > MAX_FILE_SIZE) { - return errorResponse('File too large. Maximum size is 100MB', 413); + if (file.size > maxFileSize) { + return errorResponse(`File too large. Maximum size is ${Math.floor(maxFileSize / (1024 * 1024))}MB`, 413); } - // Store file in R2 - const path = getAttachmentPath(cipherId, attachmentId); - await env.ATTACHMENTS.put(path, file.stream(), { - httpMetadata: { + const path = getAttachmentObjectKey(cipherId, attachmentId); + try { + await putBlobObject(env, path, file.stream(), { + size: file.size, contentType: 'application/octet-stream', - }, - customMetadata: { - cipherId: cipherId, - attachmentId: attachmentId, - }, - }); + customMetadata: { + cipherId, + attachmentId, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('KV object too large')) { + return errorResponse(`File too large. Maximum size is ${Math.floor(maxFileSize / (1024 * 1024))}MB`, 413); + } + return errorResponse('Attachment storage is not configured', 500); + } // Update attachment size if different const actualSize = file.size; @@ -242,9 +248,8 @@ export async function handlePublicDownloadAttachment( return errorResponse('Attachment not found', 404); } - // Get file from R2 - const path = getAttachmentPath(cipherId, attachmentId); - const object = await env.ATTACHMENTS.get(path); + const path = getAttachmentObjectKey(cipherId, attachmentId); + const object = await getBlobObject(env, path); if (!object) { return errorResponse('Attachment file not found', 404); @@ -257,7 +262,7 @@ export async function handlePublicDownloadAttachment( return new Response(object.body, { headers: { - 'Content-Type': 'application/octet-stream', + 'Content-Type': object.contentType || 'application/octet-stream', 'Content-Length': String(object.size), 'Cache-Control': 'private, no-cache', }, @@ -287,9 +292,8 @@ export async function handleDeleteAttachment( return errorResponse('Attachment not found', 404); } - // Delete file from R2 - const path = getAttachmentPath(cipherId, attachmentId); - await env.ATTACHMENTS.delete(path); + const path = getAttachmentObjectKey(cipherId, attachmentId); + await deleteBlobObject(env, path); // Delete attachment metadata await storage.deleteAttachment(attachmentId); @@ -318,8 +322,8 @@ export async function deleteAllAttachmentsForCipher( const attachments = await storage.getAttachmentsByCipher(cipherId); for (const attachment of attachments) { - const path = getAttachmentPath(cipherId, attachment.id); - await env.ATTACHMENTS.delete(path); + const path = getAttachmentObjectKey(cipherId, attachment.id); + await deleteBlobObject(env, path); await storage.deleteAttachment(attachment.id); } } diff --git a/src/handlers/sends.ts b/src/handlers/sends.ts index 34f837f..3f55337 100644 --- a/src/handlers/sends.ts +++ b/src/handlers/sends.ts @@ -11,6 +11,13 @@ import { verifySendAccessToken, verifySendFileDownloadToken, } from '../utils/jwt'; +import { + deleteBlobObject, + getBlobObject, + getBlobStorageMaxBytes, + getSendFileObjectKey, + putBlobObject, +} from '../services/blob-store'; const SEND_INACCESSIBLE_MSG = 'Send does not exist or is no longer available'; const SEND_PASSWORD_ITERATIONS = 100_000; @@ -142,10 +149,6 @@ function normalizeSendDataSizeField(data: Record): Record { const storage = new StorageService(env.DB); + const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes); let body: unknown; try { @@ -626,7 +630,7 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId: const fileLengthRaw = getAliasedProp(body, ['fileLength', 'FileLength']); const fileLengthParsed = parseFileLength(fileLengthRaw.value); if (!fileLengthParsed.ok) return fileLengthParsed.response; - if (fileLengthParsed.value > LIMITS.send.maxFileSizeBytes) { + if (fileLengthParsed.value > maxFileSize) { return errorResponse('Send storage limit exceeded with this file', 400); } @@ -774,6 +778,7 @@ export async function handleUploadSendFile( fileId: string ): Promise { const storage = new StorageService(env.DB); + const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes); const send = await storage.getSend(sendId); if (!send || send.userId !== userId) { return errorResponse('Send not found. Unable to save the file.', 404); @@ -799,7 +804,7 @@ export async function handleUploadSendFile( return errorResponse('No file uploaded', 400); } - if (file.size > LIMITS.send.maxFileSizeBytes) { + if (file.size > maxFileSize) { return errorResponse('Send storage limit exceeded with this file', 413); } @@ -813,15 +818,22 @@ export async function handleUploadSendFile( return errorResponse('Send file size does not match.', 400); } - await env.ATTACHMENTS.put(getSendFilePath(sendId, fileId), file.stream(), { - httpMetadata: { + try { + await putBlobObject(env, getSendFileObjectKey(sendId, fileId), file.stream(), { + size: file.size, contentType: 'application/octet-stream', - }, - customMetadata: { - sendId, - fileId, - }, - }); + customMetadata: { + sendId, + fileId, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('KV object too large')) { + return errorResponse('Send storage limit exceeded with this file', 413); + } + return errorResponse('Attachment storage is not configured', 500); + } await storage.updateRevisionDate(userId); @@ -987,7 +999,7 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin const data = parseStoredSendData(send); const fileId = typeof data.id === 'string' ? data.id : null; if (fileId) { - await env.ATTACHMENTS.delete(getSendFilePath(send.id, fileId)); + await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId)); } } @@ -1282,7 +1294,7 @@ export async function handleDownloadSendFile( } const storage = new StorageService(env.DB); - const object = await env.ATTACHMENTS.get(getSendFilePath(sendId, fileId)); + const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId)); if (!object) { return errorResponse('Send file not found', 404); } @@ -1296,7 +1308,7 @@ export async function handleDownloadSendFile( return new Response(object.body, { headers: { - 'Content-Type': 'application/octet-stream', + 'Content-Type': object.contentType || 'application/octet-stream', 'Content-Length': String(object.size), 'Cache-Control': 'private, no-cache', }, diff --git a/src/services/blob-store.ts b/src/services/blob-store.ts new file mode 100644 index 0000000..1d5f9a4 --- /dev/null +++ b/src/services/blob-store.ts @@ -0,0 +1,124 @@ +import { Env } from '../types'; + +const DEFAULT_CONTENT_TYPE = 'application/octet-stream'; +export const KV_MAX_OBJECT_BYTES = 25 * 1024 * 1024; + +interface KVBlobMetadata { + size?: number; + contentType?: string; + customMetadata?: Record | null; +} + +export interface BlobObject { + body: ReadableStream | null; + size: number; + contentType: string; +} + +export interface PutBlobOptions { + size: number; + contentType?: string; + customMetadata?: Record; +} + +function hasR2Storage(env: Env): env is Env & { ATTACHMENTS: R2Bucket } { + return !!env.ATTACHMENTS; +} + +function hasKvStorage(env: Env): env is Env & { ATTACHMENTS_KV: KVNamespace } { + return !!env.ATTACHMENTS_KV; +} + +export function getBlobStorageKind(env: Env): 'r2' | 'kv' | null { + // Keep R2 as preferred backend when both are bound. + if (hasR2Storage(env)) return 'r2'; + if (hasKvStorage(env)) return 'kv'; + return null; +} + +export function getBlobStorageMaxBytes(env: Env, configuredLimit: number): number { + if (getBlobStorageKind(env) === 'kv') { + return Math.min(configuredLimit, KV_MAX_OBJECT_BYTES); + } + return configuredLimit; +} + +export function getAttachmentObjectKey(cipherId: string, attachmentId: string): string { + return `${cipherId}/${attachmentId}`; +} + +export function getSendFileObjectKey(sendId: string, fileId: string): string { + return `sends/${sendId}/${fileId}`; +} + +export async function putBlobObject( + env: Env, + key: string, + value: string | ArrayBuffer | ArrayBufferView | ReadableStream, + options: PutBlobOptions +): Promise { + const contentType = options.contentType || DEFAULT_CONTENT_TYPE; + + if (hasR2Storage(env)) { + await env.ATTACHMENTS.put(key, value, { + httpMetadata: { contentType }, + customMetadata: options.customMetadata, + }); + return; + } + + if (hasKvStorage(env)) { + if (options.size > KV_MAX_OBJECT_BYTES) { + throw new Error('KV object too large'); + } + const metadata: KVBlobMetadata = { + size: options.size, + contentType, + customMetadata: options.customMetadata || null, + }; + await env.ATTACHMENTS_KV.put(key, value, { metadata }); + return; + } + + throw new Error('Attachment storage is not configured'); +} + +export async function getBlobObject(env: Env, key: string): Promise { + if (hasR2Storage(env)) { + const object = await env.ATTACHMENTS.get(key); + if (!object) return null; + return { + body: object.body, + size: Number(object.size) || 0, + contentType: object.httpMetadata?.contentType || DEFAULT_CONTENT_TYPE, + }; + } + + if (hasKvStorage(env)) { + const result = await env.ATTACHMENTS_KV.getWithMetadata(key, 'arrayBuffer'); + if (!result.value) return null; + + const sizeFromMeta = Number(result.metadata?.size || 0); + const size = sizeFromMeta > 0 ? sizeFromMeta : result.value.byteLength; + const body = new Response(result.value).body; + + return { + body, + size, + contentType: result.metadata?.contentType || DEFAULT_CONTENT_TYPE, + }; + } + + return null; +} + +export async function deleteBlobObject(env: Env, key: string): Promise { + if (hasR2Storage(env)) { + await env.ATTACHMENTS.delete(key); + return; + } + if (hasKvStorage(env)) { + await env.ATTACHMENTS_KV.delete(key); + return; + } +} diff --git a/src/services/ratelimit.ts b/src/services/ratelimit.ts index bc3d0b5..590b608 100644 --- a/src/services/ratelimit.ts +++ b/src/services/ratelimit.ts @@ -299,6 +299,29 @@ function normalizeClientIpForRateLimit(rawIp: string): string | null { return `ip6:${prefix64}`; } +function isLocalRequest(request: Request): boolean { + const isLoopbackHost = (host: string | null): boolean => { + if (!host) return false; + const normalized = host.split(':')[0].trim().toLowerCase(); + return ( + normalized === 'localhost' || + normalized.endsWith('.localhost') || + normalized === '127.0.0.1' || + normalized === '0.0.0.0' || + normalized === '::1' || + normalized === '[::1]' + ); + }; + + try { + if (isLoopbackHost(new URL(request.url).hostname)) return true; + } catch { + // Ignore malformed URL and fall back to Host header check. + } + + return isLoopbackHost(request.headers.get('Host')); +} + export function getClientIdentifier(request: Request): string | null { // Strict fallback order: // 1) CF-Connecting-IP @@ -317,5 +340,10 @@ export function getClientIdentifier(request: Request): string | null { if (normalized) return normalized; } + // Local dev (wrangler dev / localhost): allow a deterministic loopback identifier. + if (isLocalRequest(request)) { + return 'ip4:127.0.0.1'; + } + return null; } diff --git a/src/types/index.ts b/src/types/index.ts index 2e662a6..b1c4a53 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,7 +1,10 @@ // Environment bindings export interface Env { DB: D1Database; - ATTACHMENTS: R2Bucket; + // Prefer R2 when available. Optional to support KV-only deployments. + ATTACHMENTS?: R2Bucket; + // Optional fallback for attachment/send file storage (no credit card required). + ATTACHMENTS_KV?: KVNamespace; JWT_SECRET: string; TOTP_SECRET?: string; } diff --git a/wrangler.kv.toml b/wrangler.kv.toml new file mode 100644 index 0000000..d4121c0 --- /dev/null +++ b/wrangler.kv.toml @@ -0,0 +1,15 @@ +name = "nodewarden" +main = "src/index.ts" +compatibility_date = "2024-01-01" +assets = { directory = "./dist", not_found_handling = "single-page-application", run_worker_first = ["/api/*", "/identity/*", "/icons/*", "/setup/*", "/config", "/notifications/*", "/.well-known/*"] } + +[build] +command = "npm run build" + +[[d1_databases]] +binding = "DB" +database_name = "nodewarden-db" + +[[kv_namespaces]] +binding = "ATTACHMENTS_KV" +id = "REPLACE_WITH_KV_NAMESPACE_ID"