mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: add support for KV storage mode and enhance attachment handling
This commit is contained in:
@@ -8,7 +8,8 @@
|
|||||||
|
|
||||||
[](https://workers.cloudflare.com/)
|
[](https://workers.cloudflare.com/)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/NodeWarden)
|
[-Cloudflare%20Workers-F38020?logo=cloudflare&logoColor=white)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/NodeWarden)
|
||||||
|
[-Cloudflare%20Workers-2ea44f?logo=cloudflare&logoColor=white)](./README_EN.md#kv-mode-no-credit-card)
|
||||||
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||||
[](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
|
[](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ English:[`README_EN.md`](./README_EN.md)
|
|||||||
| Web Vault(登录/笔记/卡片/身份) | ✅ | ✅ | 网页端密码库管理页面 |
|
| Web Vault(登录/笔记/卡片/身份) | ✅ | ✅ | 网页端密码库管理页面 |
|
||||||
| 文件夹 / 收藏 | ✅ | ✅ | 常用管理能力可用 |
|
| 文件夹 / 收藏 | ✅ | ✅ | 常用管理能力可用 |
|
||||||
| 全量同步 `/api/sync` | ✅ | ✅ | 已做兼容与性能优化 |
|
| 全量同步 `/api/sync` | ✅ | ✅ | 已做兼容与性能优化 |
|
||||||
| 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 |
|
| 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2(或可选 KV 模式) |
|
||||||
| 导入导出功能 | ✅ | ✅ | 完整实现,含 Bitwarden 密码库+附件 ZIP 导入 |
|
| 导入导出功能 | ✅ | ✅ | 完整实现,含 Bitwarden 密码库+附件 ZIP 导入 |
|
||||||
| 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` |
|
| 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` |
|
||||||
| passkey、TOTP字段 | ❌ | ✅ |官方需要会员,我们的不需要 |
|
| passkey、TOTP字段 | ❌ | ✅ |官方需要会员,我们的不需要 |
|
||||||
@@ -58,14 +59,22 @@ English:[`README_EN.md`](./README_EN.md)
|
|||||||
**部署步骤:**
|
**部署步骤:**
|
||||||
|
|
||||||
1. 首先Fork本仓库,命名为**NodeWarden**
|
1. 首先Fork本仓库,命名为**NodeWarden**
|
||||||
2. 点击下面的一键部署按钮,修改项目名称为**NodeWarden2**,修改**JWT_SECRET**成32为随机字符串
|
2. 点击下面的一键部署按钮,修改项目名称为**NodeWarden2**,修改**JWT_SECRET**成32为随机字符串;
|
||||||
3. [](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
|
**若无信用卡,储存库可选KV模式**,一键部署页面里部署命令改成:npm run deploy:kv
|
||||||
4. 部署完成后,同一页面打开workers设置,将**Git存储库**断开连接
|
|
||||||
5. 同一位置,**Git存储库**链接至第一步Fork的仓库
|
|
||||||
|
|
||||||
**同步上游(更新):**
|
[](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
|
||||||
- 手动:Github打开你Fork的私人仓库,看到顶部同步提示时,点击 “Sync fork”。
|
|
||||||
- 自动:进入你的 Fork 仓库 → Actions,点击 “I understand my workflows, go ahead and enable them”,每天凌晨三点自动同步至上游
|
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 部署
|
### CLI 部署
|
||||||
|
|
||||||
@@ -87,6 +96,11 @@ npx wrangler r2 bucket create nodewarden-attachments
|
|||||||
# 部署
|
# 部署
|
||||||
npm run deploy
|
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
|
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||||
cd NodeWarden
|
cd NodeWarden
|
||||||
|
|||||||
+21
-9
@@ -8,7 +8,8 @@
|
|||||||
|
|
||||||
[](https://workers.cloudflare.com/)
|
[](https://workers.cloudflare.com/)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/NodeWarden)
|
[-Cloudflare%20Workers-F38020?logo=cloudflare&logoColor=white)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/NodeWarden)
|
||||||
|
[-Cloudflare%20Workers-2ea44f?logo=cloudflare&logoColor=white)](#kv-mode-no-credit-card)
|
||||||
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||||
[](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
|
[](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 |
|
| Web Vault (logins/notes/cards/identities) | ✅ | ✅ | Web-based vault management UI |
|
||||||
| Folders / Favorites | ✅ | ✅ | Common vault organization supported |
|
| Folders / Favorites | ✅ | ✅ | Common vault organization supported |
|
||||||
| Full sync `/api/sync` | ✅ | ✅ | Compatibility and performance optimized |
|
| 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. |
|
| mport / export | ✅ | ✅ | Fully implemented, including Bitwarden vault + attachments ZIP import. |
|
||||||
| Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` |
|
| Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` |
|
||||||
| passkey、TOTP fields | ❌ | ✅ | Official service requires premium; NodeWarden does not |
|
| passkey、TOTP fields | ❌ | ✅ | Official service requires premium; NodeWarden does not |
|
||||||
@@ -59,14 +60,20 @@
|
|||||||
**Deploy steps:**
|
**Deploy steps:**
|
||||||
|
|
||||||
1. Fork this repository and name it **NodeWarden**.
|
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.
|
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`.
|
||||||
3. [](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.
|
|
||||||
|
|
||||||
**Sync upstream (update):**
|
[](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
|
||||||
- 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.
|
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
|
### CLI deploy
|
||||||
|
|
||||||
@@ -88,6 +95,11 @@ npx wrangler r2 bucket create nodewarden-attachments
|
|||||||
# Deploy
|
# Deploy
|
||||||
npm run 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
|
# To update later: re-clone and re-deploy — no need to recreate cloud resources
|
||||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||||
cd NodeWarden
|
cd NodeWarden
|
||||||
|
|||||||
+6
-1
@@ -8,8 +8,10 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "wrangler dev -c wrangler.toml",
|
"dev": "wrangler dev -c wrangler.toml",
|
||||||
|
"dev:kv": "wrangler dev -c wrangler.kv.toml",
|
||||||
"build": "vite build --config webapp/vite.config.ts",
|
"build": "vite build --config webapp/vite.config.ts",
|
||||||
"deploy": "wrangler deploy"
|
"deploy": "wrangler deploy",
|
||||||
|
"deploy:kv": "wrangler deploy -c wrangler.kv.toml"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"bitwarden",
|
"bitwarden",
|
||||||
@@ -28,6 +30,9 @@
|
|||||||
},
|
},
|
||||||
"ATTACHMENTS": {
|
"ATTACHMENTS": {
|
||||||
"description": "R2 bucket for storing file attachments"
|
"description": "R2 bucket for storing file attachments"
|
||||||
|
},
|
||||||
|
"ATTACHMENTS_KV": {
|
||||||
|
"description": "Optional KV namespace fallback for attachment/send-file storage"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
send: {
|
send: {
|
||||||
// Max file size allowed for Send file uploads.
|
// Max file size allowed for Send file uploads.
|
||||||
// Send 文件上传大小上限。
|
// Send 文件上传大小上限。
|
||||||
maxFileSizeBytes: 550_502_400,
|
maxFileSizeBytes: 100 * 1024 * 1024,
|
||||||
// Max days allowed between now and deletion date.
|
// Max days allowed between now and deletion date.
|
||||||
// 允许的最远删除日期(距当前天数)。
|
// 允许的最远删除日期(距当前天数)。
|
||||||
maxDeletionDays: 31,
|
maxDeletionDays: 31,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Env, User, Invite } from '../types';
|
|||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { deleteBlobObject, getAttachmentObjectKey, getSendFileObjectKey } from '../services/blob-store';
|
||||||
|
|
||||||
function isAdmin(user: User): boolean {
|
function isAdmin(user: User): boolean {
|
||||||
return user.role === 'admin' && user.status === 'active';
|
return user.role === 'admin' && user.status === 'active';
|
||||||
@@ -260,7 +261,7 @@ export async function handleAdminDeleteUser(
|
|||||||
const attachmentMap = await storage.getAttachmentsByUserId(target.id);
|
const attachmentMap = await storage.getAttachmentsByUserId(target.id);
|
||||||
for (const [cipherId, attachments] of attachmentMap) {
|
for (const [cipherId, attachments] of attachmentMap) {
|
||||||
for (const att of attachments) {
|
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)
|
// 2. Send files (keyed by sends/sendId/fileId)
|
||||||
@@ -271,7 +272,7 @@ export async function handleAdminDeleteUser(
|
|||||||
const parsed = JSON.parse(send.data) as Record<string, unknown>;
|
const parsed = JSON.parse(send.data) as Record<string, unknown>;
|
||||||
const fileId = typeof parsed.id === 'string' ? parsed.id : null;
|
const fileId = typeof parsed.id === 'string' ? parsed.id : null;
|
||||||
if (fileId) {
|
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 */ }
|
} catch { /* non-file send or bad data, skip */ }
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-31
@@ -5,6 +5,13 @@ import { generateUUID } from '../utils/uuid';
|
|||||||
import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
|
import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
|
||||||
import { cipherToResponse } from './ciphers';
|
import { cipherToResponse } from './ciphers';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
|
import {
|
||||||
|
deleteBlobObject,
|
||||||
|
getAttachmentObjectKey,
|
||||||
|
getBlobObject,
|
||||||
|
getBlobStorageMaxBytes,
|
||||||
|
putBlobObject,
|
||||||
|
} from '../services/blob-store';
|
||||||
|
|
||||||
// Format file size to human readable
|
// Format file size to human readable
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
@@ -14,11 +21,6 @@ function formatSize(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
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
|
// POST /api/ciphers/{cipherId}/attachment/v2
|
||||||
// Creates attachment metadata and returns upload URL
|
// Creates attachment metadata and returns upload URL
|
||||||
export async function handleCreateAttachment(
|
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}
|
// POST /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||||
// Upload attachment file content
|
// Upload attachment file content
|
||||||
export async function handleUploadAttachment(
|
export async function handleUploadAttachment(
|
||||||
@@ -99,6 +98,7 @@ export async function handleUploadAttachment(
|
|||||||
attachmentId: string
|
attachmentId: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.attachment.maxFileSizeBytes);
|
||||||
|
|
||||||
// Verify cipher exists and belongs to user
|
// Verify cipher exists and belongs to user
|
||||||
const cipher = await storage.getCipher(cipherId);
|
const cipher = await storage.getCipher(cipherId);
|
||||||
@@ -114,8 +114,8 @@ export async function handleUploadAttachment(
|
|||||||
|
|
||||||
// Check content-length header for size limit
|
// Check content-length header for size limit
|
||||||
const contentLength = request.headers.get('content-length');
|
const contentLength = request.headers.get('content-length');
|
||||||
if (contentLength && parseInt(contentLength) > MAX_FILE_SIZE) {
|
if (contentLength && parseInt(contentLength) > maxFileSize) {
|
||||||
return errorResponse('File too large. Maximum size is 100MB', 413);
|
return errorResponse(`File too large. Maximum size is ${Math.floor(maxFileSize / (1024 * 1024))}MB`, 413);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the file from multipart form data
|
// Get the file from multipart form data
|
||||||
@@ -132,21 +132,27 @@ export async function handleUploadAttachment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check actual file size
|
// Check actual file size
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > maxFileSize) {
|
||||||
return errorResponse('File too large. Maximum size is 100MB', 413);
|
return errorResponse(`File too large. Maximum size is ${Math.floor(maxFileSize / (1024 * 1024))}MB`, 413);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store file in R2
|
const path = getAttachmentObjectKey(cipherId, attachmentId);
|
||||||
const path = getAttachmentPath(cipherId, attachmentId);
|
try {
|
||||||
await env.ATTACHMENTS.put(path, file.stream(), {
|
await putBlobObject(env, path, file.stream(), {
|
||||||
httpMetadata: {
|
size: file.size,
|
||||||
contentType: 'application/octet-stream',
|
contentType: 'application/octet-stream',
|
||||||
},
|
customMetadata: {
|
||||||
customMetadata: {
|
cipherId,
|
||||||
cipherId: cipherId,
|
attachmentId,
|
||||||
attachmentId: 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
|
// Update attachment size if different
|
||||||
const actualSize = file.size;
|
const actualSize = file.size;
|
||||||
@@ -242,9 +248,8 @@ export async function handlePublicDownloadAttachment(
|
|||||||
return errorResponse('Attachment not found', 404);
|
return errorResponse('Attachment not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file from R2
|
const path = getAttachmentObjectKey(cipherId, attachmentId);
|
||||||
const path = getAttachmentPath(cipherId, attachmentId);
|
const object = await getBlobObject(env, path);
|
||||||
const object = await env.ATTACHMENTS.get(path);
|
|
||||||
|
|
||||||
if (!object) {
|
if (!object) {
|
||||||
return errorResponse('Attachment file not found', 404);
|
return errorResponse('Attachment file not found', 404);
|
||||||
@@ -257,7 +262,7 @@ export async function handlePublicDownloadAttachment(
|
|||||||
|
|
||||||
return new Response(object.body, {
|
return new Response(object.body, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': object.contentType || 'application/octet-stream',
|
||||||
'Content-Length': String(object.size),
|
'Content-Length': String(object.size),
|
||||||
'Cache-Control': 'private, no-cache',
|
'Cache-Control': 'private, no-cache',
|
||||||
},
|
},
|
||||||
@@ -287,9 +292,8 @@ export async function handleDeleteAttachment(
|
|||||||
return errorResponse('Attachment not found', 404);
|
return errorResponse('Attachment not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete file from R2
|
const path = getAttachmentObjectKey(cipherId, attachmentId);
|
||||||
const path = getAttachmentPath(cipherId, attachmentId);
|
await deleteBlobObject(env, path);
|
||||||
await env.ATTACHMENTS.delete(path);
|
|
||||||
|
|
||||||
// Delete attachment metadata
|
// Delete attachment metadata
|
||||||
await storage.deleteAttachment(attachmentId);
|
await storage.deleteAttachment(attachmentId);
|
||||||
@@ -318,8 +322,8 @@ export async function deleteAllAttachmentsForCipher(
|
|||||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
|
|
||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
const path = getAttachmentPath(cipherId, attachment.id);
|
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
||||||
await env.ATTACHMENTS.delete(path);
|
await deleteBlobObject(env, path);
|
||||||
await storage.deleteAttachment(attachment.id);
|
await storage.deleteAttachment(attachment.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-17
@@ -11,6 +11,13 @@ import {
|
|||||||
verifySendAccessToken,
|
verifySendAccessToken,
|
||||||
verifySendFileDownloadToken,
|
verifySendFileDownloadToken,
|
||||||
} from '../utils/jwt';
|
} 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_INACCESSIBLE_MSG = 'Send does not exist or is no longer available';
|
||||||
const SEND_PASSWORD_ITERATIONS = 100_000;
|
const SEND_PASSWORD_ITERATIONS = 100_000;
|
||||||
@@ -142,10 +149,6 @@ function normalizeSendDataSizeField(data: Record<string, unknown>): Record<strin
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSendFilePath(sendId: string, fileId: string): string {
|
|
||||||
return `sends/${sendId}/${fileId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSendAvailable(send: Send): boolean {
|
export function isSendAvailable(send: Send): boolean {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
@@ -609,6 +612,7 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin
|
|||||||
// POST /api/sends/file/v2
|
// POST /api/sends/file/v2
|
||||||
export async function handleCreateFileSendV2(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleCreateFileSendV2(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes);
|
||||||
|
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
try {
|
try {
|
||||||
@@ -626,7 +630,7 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId:
|
|||||||
const fileLengthRaw = getAliasedProp(body, ['fileLength', 'FileLength']);
|
const fileLengthRaw = getAliasedProp(body, ['fileLength', 'FileLength']);
|
||||||
const fileLengthParsed = parseFileLength(fileLengthRaw.value);
|
const fileLengthParsed = parseFileLength(fileLengthRaw.value);
|
||||||
if (!fileLengthParsed.ok) return fileLengthParsed.response;
|
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);
|
return errorResponse('Send storage limit exceeded with this file', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -774,6 +778,7 @@ export async function handleUploadSendFile(
|
|||||||
fileId: string
|
fileId: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes);
|
||||||
const send = await storage.getSend(sendId);
|
const send = await storage.getSend(sendId);
|
||||||
if (!send || send.userId !== userId) {
|
if (!send || send.userId !== userId) {
|
||||||
return errorResponse('Send not found. Unable to save the file.', 404);
|
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);
|
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);
|
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);
|
return errorResponse('Send file size does not match.', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
await env.ATTACHMENTS.put(getSendFilePath(sendId, fileId), file.stream(), {
|
try {
|
||||||
httpMetadata: {
|
await putBlobObject(env, getSendFileObjectKey(sendId, fileId), file.stream(), {
|
||||||
|
size: file.size,
|
||||||
contentType: 'application/octet-stream',
|
contentType: 'application/octet-stream',
|
||||||
},
|
customMetadata: {
|
||||||
customMetadata: {
|
sendId,
|
||||||
sendId,
|
fileId,
|
||||||
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);
|
await storage.updateRevisionDate(userId);
|
||||||
|
|
||||||
@@ -987,7 +999,7 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin
|
|||||||
const data = parseStoredSendData(send);
|
const data = parseStoredSendData(send);
|
||||||
const fileId = typeof data.id === 'string' ? data.id : null;
|
const fileId = typeof data.id === 'string' ? data.id : null;
|
||||||
if (fileId) {
|
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 storage = new StorageService(env.DB);
|
||||||
const object = await env.ATTACHMENTS.get(getSendFilePath(sendId, fileId));
|
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
|
||||||
if (!object) {
|
if (!object) {
|
||||||
return errorResponse('Send file not found', 404);
|
return errorResponse('Send file not found', 404);
|
||||||
}
|
}
|
||||||
@@ -1296,7 +1308,7 @@ export async function handleDownloadSendFile(
|
|||||||
|
|
||||||
return new Response(object.body, {
|
return new Response(object.body, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': object.contentType || 'application/octet-stream',
|
||||||
'Content-Length': String(object.size),
|
'Content-Length': String(object.size),
|
||||||
'Cache-Control': 'private, no-cache',
|
'Cache-Control': 'private, no-cache',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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<string, string> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlobObject {
|
||||||
|
body: ReadableStream | null;
|
||||||
|
size: number;
|
||||||
|
contentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PutBlobOptions {
|
||||||
|
size: number;
|
||||||
|
contentType?: string;
|
||||||
|
customMetadata?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<BlobObject | null> {
|
||||||
|
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<KVBlobMetadata>(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<void> {
|
||||||
|
if (hasR2Storage(env)) {
|
||||||
|
await env.ATTACHMENTS.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasKvStorage(env)) {
|
||||||
|
await env.ATTACHMENTS_KV.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -299,6 +299,29 @@ function normalizeClientIpForRateLimit(rawIp: string): string | null {
|
|||||||
return `ip6:${prefix64}`;
|
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 {
|
export function getClientIdentifier(request: Request): string | null {
|
||||||
// Strict fallback order:
|
// Strict fallback order:
|
||||||
// 1) CF-Connecting-IP
|
// 1) CF-Connecting-IP
|
||||||
@@ -317,5 +340,10 @@ export function getClientIdentifier(request: Request): string | null {
|
|||||||
if (normalized) return normalized;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -1,7 +1,10 @@
|
|||||||
// Environment bindings
|
// Environment bindings
|
||||||
export interface Env {
|
export interface Env {
|
||||||
DB: D1Database;
|
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;
|
JWT_SECRET: string;
|
||||||
TOTP_SECRET?: string;
|
TOTP_SECRET?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user