Improve API response formatting and structure in handlers

This commit is contained in:
shuaiplus
2026-02-11 23:53:36 +08:00
parent c825280707
commit b33ee64c58
16 changed files with 87 additions and 137 deletions
+6 -6
View File
@@ -27,9 +27,9 @@ A **Bitwarden-compatible** server that runs on **Cloudflare Workers**.
## Tested clients / platforms ## Tested clients / platforms
- ✅ Windows desktop clientv2026.1.0 - ✅ Windows desktop client (v2026.1.0)
- ✅ Android app v2026.1.0 - ✅ Android app (v2026.1.0)
- ✅ Browser extensionv2026.1.0 - ✅ Browser extension (v2026.1.0)
- ⬜ macOS desktop client (not tested) - ⬜ macOS desktop client (not tested)
- ⬜ Linux desktop client (not tested) - ⬜ Linux desktop client (not tested)
@@ -39,14 +39,14 @@ A **Bitwarden-compatible** server that runs on **Cloudflare Workers**.
### One-click deploy ### One-click deploy
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden/tree/d1-test) [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
**Deploy steps:** **Deploy steps:**
1. Sign in with GitHub and authorize 1. Sign in with GitHub and authorize
2. Sign in to Cloudflare 2. Sign in to Cloudflare
3. **Important**: set `JWT_SECRET` to a strong random string (recommended: `openssl rand -hex 32`) 3. **Important**: set `JWT_SECRET` to a strong random string (recommended: `openssl rand -hex 32`)
4. KV namespace and R2 bucket will be created automatically 4. D1 database and R2 bucket will be created automatically
5. Click **Deploy** and wait for it to finish 5. Click **Deploy** and wait for it to finish
6. After deploy, open the Cloudflare-provided Workers URL (your service URL), and register on the web page 6. After deploy, open the Cloudflare-provided Workers URL (your service URL), and register on the web page
@@ -75,7 +75,7 @@ npm run dev
## Tech stack ## Tech stack
- **Runtime**: Cloudflare Workers - **Runtime**: Cloudflare Workers
- **Data storage**: Cloudflare KV - **Data storage**: Cloudflare D1 (SQLite)
- **File storage**: Cloudflare R2 - **File storage**: Cloudflare R2
- **Language**: TypeScript - **Language**: TypeScript
- **Crypto**: Client-side AES-256-CBC, JWT uses HS256 - **Crypto**: Client-side AES-256-CBC, JWT uses HS256
+2 -2
View File
@@ -47,7 +47,7 @@ English[`README.md`](./README.md)
1. 使用 GitHub 登录并授权 1. 使用 GitHub 登录并授权
2. 登录 Cloudflare 账户 2. 登录 Cloudflare 账户
3. **重要**:设置 `JWT_SECRET` 为强随机字符串(推荐使用 `openssl rand -hex 32` 生成) 3. **重要**:设置 `JWT_SECRET` 为强随机字符串(推荐使用 `openssl rand -hex 32` 生成)
4. KV 存储和 R2 存储桶将自动创建 4. D1 数据库和 R2 存储桶将自动创建
5. 点击 Deploy 等待部署完成 5. 点击 Deploy 等待部署完成
6. 部署完成后,先打开 Cloudflare 给你的 Workers 链接(也就是你的服务地址),在网页上填写信息完成注册。 6. 部署完成后,先打开 Cloudflare 给你的 Workers 链接(也就是你的服务地址),在网页上填写信息完成注册。
@@ -81,7 +81,7 @@ npm run dev
## 技术栈 ## 技术栈
- **运行环境**Cloudflare Workers - **运行环境**Cloudflare Workers
- **数据存储**Cloudflare KV - **数据存储**Cloudflare D1SQLite
- **文件存储**Cloudflare R2 - **文件存储**Cloudflare R2
- **开发语言**TypeScript - **开发语言**TypeScript
- **加密算法**:客户端 AES-256-CBCJWT 使用 HS256 - **加密算法**:客户端 AES-256-CBCJWT 使用 HS256
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "0.1.0", "version": "0.2.0",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers", "description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus", "author": "shuaiplus",
"license": "LGPL-3.0", "license": "LGPL-3.0",
@@ -21,13 +21,13 @@
"cloudflare": { "cloudflare": {
"bindings": { "bindings": {
"JWT_SECRET": { "JWT_SECRET": {
"description": "用于签名 JWT 的密钥。请输入一个随机的复杂字符串(建议 32 位以上)" "description": "Secret used to sign JWTs. Use a strong random string (32+ characters recommended)"
}, },
"VAULT": { "DB": {
"description": "用于存储密码库数据的 KV 存储" "description": "D1 database for storing vault data"
}, },
"ATTACHMENTS": { "ATTACHMENTS": {
"description": "用于存储文件附件的 R2 存储桶" "description": "R2 bucket for storing file attachments"
} }
} }
}, },
+4 -48
View File
@@ -1,8 +1,9 @@
import { Env, Attachment, Cipher } from '../types'; import { Env, Attachment } 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 { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt'; import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
import { cipherToResponse } from './ciphers';
// Format file size to human readable // Format file size to human readable
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
@@ -80,7 +81,7 @@ export async function handleCreateAttachment(
attachmentId: attachmentId, attachmentId: attachmentId,
url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`, url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`,
fileUploadType: 0, // Direct upload fileUploadType: 0, // Direct upload
cipherResponse: formatCipherResponse(updatedCipher!, attachments), cipherResponse: cipherToResponse(updatedCipher!, attachments),
}); });
} }
@@ -295,55 +296,10 @@ export async function handleDeleteAttachment(
const attachments = await storage.getAttachmentsByCipher(cipherId); const attachments = await storage.getAttachmentsByCipher(cipherId);
return jsonResponse({ return jsonResponse({
cipher: formatCipherResponse(updatedCipher!, attachments), cipher: cipherToResponse(updatedCipher!, attachments),
}); });
} }
// Helper: Format cipher response with attachments
function formatCipherResponse(cipher: Cipher, attachments: Attachment[]): any {
return {
id: cipher.id,
organizationId: null,
folderId: cipher.folderId,
type: Number(cipher.type) || 1,
name: cipher.name,
notes: cipher.notes,
favorite: cipher.favorite,
login: cipher.login,
card: cipher.card,
identity: cipher.identity,
secureNote: cipher.secureNote,
sshKey: cipher.sshKey,
fields: cipher.fields,
passwordHistory: cipher.passwordHistory,
reprompt: cipher.reprompt,
organizationUseTotp: false,
creationDate: cipher.createdAt,
revisionDate: cipher.updatedAt,
deletedDate: cipher.deletedAt,
archivedDate: null,
edit: true,
viewPassword: true,
permissions: {
delete: true,
restore: true,
},
object: 'cipher',
collectionIds: [],
attachments: attachments.length > 0 ? attachments.map(a => ({
id: a.id,
fileName: a.fileName,
size: Number(a.size) || 0,
sizeName: a.sizeName,
key: a.key,
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`,
object: 'attachment',
})) : null,
key: cipher.key,
encryptedFor: null,
};
}
// Delete all attachments for a cipher (used when deleting cipher) // Delete all attachments for a cipher (used when deleting cipher)
export async function deleteAllAttachmentsForCipher( export async function deleteAllAttachmentsForCipher(
env: Env, env: Env,
+2 -2
View File
@@ -5,7 +5,7 @@ import { generateUUID } from '../utils/uuid';
import { deleteAllAttachmentsForCipher } from './attachments'; import { deleteAllAttachmentsForCipher } from './attachments';
// Format attachments for API response // Format attachments for API response
function formatAttachments(attachments: Attachment[]): any[] | null { export function formatAttachments(attachments: Attachment[]): any[] | null {
if (attachments.length === 0) return null; if (attachments.length === 0) return null;
return attachments.map(a => ({ return attachments.map(a => ({
id: a.id, id: a.id,
@@ -19,7 +19,7 @@ function formatAttachments(attachments: Attachment[]): any[] | null {
} }
// Convert internal cipher to API response format // Convert internal cipher to API response format
function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse { export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse {
return { return {
id: cipher.id, id: cipher.id,
organizationId: null, organizationId: null,
+3
View File
@@ -62,6 +62,7 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
}; };
await storage.saveFolder(folder); await storage.saveFolder(folder);
await storage.updateRevisionDate(userId);
return jsonResponse(folderToResponse(folder), 200); return jsonResponse(folderToResponse(folder), 200);
} }
@@ -88,6 +89,7 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
folder.updatedAt = new Date().toISOString(); folder.updatedAt = new Date().toISOString();
await storage.saveFolder(folder); await storage.saveFolder(folder);
await storage.updateRevisionDate(userId);
return jsonResponse(folderToResponse(folder)); return jsonResponse(folderToResponse(folder));
} }
@@ -102,6 +104,7 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
} }
await storage.deleteFolder(id, userId); await storage.deleteFolder(id, userId);
await storage.updateRevisionDate(userId);
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
+6 -2
View File
@@ -28,8 +28,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const passwordHash = body.password; const passwordHash = body.password;
if (!email || !passwordHash) { if (!email || !passwordHash) {
// Bitwarden clients expect OAuth-style error fields. // Bitwarden clients expect OAuth-style error fields.
return identityErrorResponse('Email and password are required', 'invalid_request', 400); return identityErrorResponse('Email and password are required', 'invalid_request', 400);
} }
const user = await storage.getUser(email); const user = await storage.getUser(email);
@@ -93,7 +93,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
Parallelism: user.kdfParallelism || null, Parallelism: user.kdfParallelism || null,
}, },
MasterKeyEncryptedUserKey: user.key, MasterKeyEncryptedUserKey: user.key,
MasterKeyWrappedUserKey: user.key,
Salt: email, // email is already lowercased above Salt: email, // email is already lowercased above
Object: 'masterPasswordUnlock',
}, },
}, },
}; };
@@ -144,7 +146,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
Parallelism: user.kdfParallelism || null, Parallelism: user.kdfParallelism || null,
}, },
MasterKeyEncryptedUserKey: user.key, MasterKeyEncryptedUserKey: user.key,
MasterKeyWrappedUserKey: user.key,
Salt: user.email.toLowerCase(), Salt: user.email.toLowerCase(),
Object: 'masterPasswordUnlock',
}, },
}, },
}; };
+3 -3
View File
@@ -192,16 +192,16 @@ const registerPageHTML = `<!DOCTYPE html>
<div class="mark" aria-label="NodeWarden">NW</div> <div class="mark" aria-label="NodeWarden">NW</div>
<div class="title"> <div class="title">
<h1 id="t_app">NodeWarden</h1> <h1 id="t_app">NodeWarden</h1>
<p id="t_tag">部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端。</p> <p id="t_tag">Minimal Bitwarden-compatible server on Cloudflare Workers.</p>
</div> </div>
</div> </div>
<div class="muted" id="t_intro" style="font-size: 13px; line-height: 1.7;"> <div class="muted" id="t_intro" style="font-size: 13px; line-height: 1.7;">
创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。 Create your first account to finish setup. Then use any official Bitwarden client to sign in.
</div> </div>
<div style="height: 14px"></div> <div style="height: 14px"></div>
<h2 id="t_setup">初始化</h2> <h2 id="t_setup">Setup</h2>
<div id="message" class="message"></div> <div id="message" class="message"></div>
+21 -49
View File
@@ -1,20 +1,7 @@
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse, Attachment } from '../types'; import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } 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 { cipherToResponse } from './ciphers';
// Format attachments for API response
function formatAttachments(attachments: Attachment[]): any[] | null {
if (attachments.length === 0) return null;
return attachments.map(a => ({
id: a.id,
fileName: a.fileName,
size: Number(a.size) || 0, // Android expects Int, not String
sizeName: a.sizeName,
key: a.key,
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url!
object: 'attachment',
}));
}
// GET /api/sync // GET /api/sync
export async function handleSync(request: Request, env: Env, userId: string): Promise<Response> { export async function handleSync(request: Request, env: Env, userId: string): Promise<Response> {
@@ -57,40 +44,8 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
const cipherResponses: CipherResponse[] = []; const cipherResponses: CipherResponse[] = [];
for (const cipher of ciphers) { for (const cipher of ciphers) {
const attachments = await storage.getAttachmentsByCipher(cipher.id); const attachments = await storage.getAttachmentsByCipher(cipher.id);
cipherResponses.push({ cipherResponses.push(cipherToResponse(cipher, attachments));
id: cipher.id, }
organizationId: null,
folderId: cipher.folderId,
type: Number(cipher.type) || 1,
name: cipher.name,
notes: cipher.notes,
favorite: cipher.favorite,
login: cipher.login,
card: cipher.card,
identity: cipher.identity,
secureNote: cipher.secureNote,
sshKey: cipher.sshKey,
fields: cipher.fields,
passwordHistory: cipher.passwordHistory,
reprompt: cipher.reprompt,
organizationUseTotp: false,
creationDate: cipher.createdAt,
revisionDate: cipher.updatedAt,
deletedDate: cipher.deletedAt,
archivedDate: null,
edit: true,
viewPassword: true,
permissions: {
delete: true,
restore: true,
},
object: 'cipher',
collectionIds: [],
attachments: formatAttachments(attachments),
key: cipher.key,
encryptedFor: null,
});
};
// Build folder responses // Build folder responses
const folderResponses: FolderResponse[] = folders.map(folder => ({ const folderResponses: FolderResponse[] = folders.map(folder => ({
@@ -112,6 +67,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
}, },
policies: [], policies: [],
sends: [], sends: [],
// PascalCase for desktop/browser clients
UserDecryptionOptions: { UserDecryptionOptions: {
HasMasterPassword: true, HasMasterPassword: true,
Object: 'userDecryptionOptions', Object: 'userDecryptionOptions',
@@ -123,7 +79,23 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
Parallelism: user.kdfParallelism || null, Parallelism: user.kdfParallelism || null,
}, },
MasterKeyEncryptedUserKey: user.key, MasterKeyEncryptedUserKey: user.key,
MasterKeyWrappedUserKey: user.key,
Salt: user.email, Salt: user.email,
Object: 'masterPasswordUnlock',
},
},
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
userDecryption: {
masterPasswordUnlock: {
kdf: {
kdfType: user.kdfType,
iterations: user.kdfIterations,
memory: user.kdfMemory || null,
parallelism: user.kdfParallelism || null,
},
masterKeyWrappedUserKey: user.key,
masterKeyEncryptedUserKey: user.key,
salt: user.email,
}, },
}, },
object: 'sync', object: 'sync',
+3 -1
View File
@@ -2,7 +2,9 @@ import { Env } from './types';
import { handleRequest } from './router'; import { handleRequest } from './router';
import { StorageService } from './services/storage'; import { StorageService } from './services/storage';
// Global flag to track if database has been initialized in this worker instance // Per-isolate flag. Each Worker isolate may have its own copy of this flag,
// but initializeDatabase() is idempotent (uses CREATE TABLE IF NOT EXISTS),
// so redundant calls are harmless and fast (single SELECT check).
let dbInitialized = false; let dbInitialized = false;
export default { export default {
+1 -1
View File
@@ -200,7 +200,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
const userId = payload.sub; const userId = payload.sub;
// API rate limiting for authenticated requests // API rate limiting for authenticated requests
const rateLimit = new RateLimitService(env.DB); const rateLimit = new RateLimitService(env.DB);
const clientId = getClientIdentifier(request); const clientId = getClientIdentifier(request);
const rateLimitCheck = await rateLimit.checkApiRateLimit(userId + ':' + clientId); const rateLimitCheck = await rateLimit.checkApiRateLimit(userId + ':' + clientId);
+1 -1
View File
@@ -6,7 +6,7 @@ export class AuthService {
private storage: StorageService; private storage: StorageService;
constructor(private env: Env) { constructor(private env: Env) {
this.storage = new StorageService(env.DB); this.storage = new StorageService(env.DB);
} }
// Verify password hash (compare with stored hash) // Verify password hash (compare with stored hash)
+10 -7
View File
@@ -10,6 +10,9 @@ export class StorageService {
constructor(private db: D1Database) {} constructor(private db: D1Database) {}
// --- Database initialization --- // --- Database initialization ---
// Idempotent auto-init for environments where D1 migrations have not been applied
// (e.g. one-click deploy). Mirrors the schema in migrations/0001_init.sql —
// keep both in sync when changing the schema.
async initializeDatabase(): Promise<void> { async initializeDatabase(): Promise<void> {
// Check if database is already initialized by looking for the config table // Check if database is already initialized by looking for the config table
@@ -245,8 +248,8 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
// --- Ciphers --- // --- Ciphers ---
async getCipher(id: string): Promise<Cipher | null> { async getCipher(id: string): Promise<Cipher | null> {
const row = await this.db.prepare('SELECT data FROM ciphers WHERE id = ?').bind(id).first<{ data: string }>(); const row = await this.db.prepare('SELECT data FROM ciphers WHERE id = ?').bind(id).first<{ data: string }>();
return row?.data ? (JSON.parse(row.data) as Cipher) : null; return row?.data ? (JSON.parse(row.data) as Cipher) : null;
} }
async saveCipher(cipher: Cipher): Promise<void> { async saveCipher(cipher: Cipher): Promise<void> {
@@ -282,7 +285,7 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
} }
async getAllCiphers(userId: string): Promise<Cipher[]> { async getAllCiphers(userId: string): Promise<Cipher[]> {
const res = await this.db.prepare('SELECT data FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC').bind(userId).all<{ data: string }>(); const res = await this.db.prepare('SELECT data FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC').bind(userId).all<{ data: string }>();
return (res.results || []).map(r => JSON.parse(r.data) as Cipher); return (res.results || []).map(r => JSON.parse(r.data) as Cipher);
} }
@@ -416,10 +419,10 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
} }
async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> { async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
// No-op: schema uses NOT NULL cipher_id. // No-op: schema uses NOT NULL cipher_id.
// Callers always delete attachment row afterwards, so this method is kept for compatibility only. // Callers always delete attachment row afterwards, so this method is kept for compatibility only.
void cipherId; void cipherId;
void attachmentId; void attachmentId;
} }
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> { async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
+20 -2
View File
@@ -149,7 +149,7 @@ export interface Folder {
export interface JWTPayload { export interface JWTPayload {
sub: string; // user id sub: string; // user id
email: string; email: string;
name: string; name: string | null;
email_verified: boolean; // required by mobile client email_verified: boolean; // required by mobile client
amr: string[]; // authentication methods reference - required by mobile client amr: string[]; // authentication methods reference - required by mobile client
sstamp: string; // security stamp - invalidates token when user changes password sstamp: string; // security stamp - invalidates token when user changes password
@@ -170,13 +170,16 @@ export interface MasterPasswordUnlockKdf {
export interface MasterPasswordUnlock { export interface MasterPasswordUnlock {
Kdf: MasterPasswordUnlockKdf; Kdf: MasterPasswordUnlockKdf;
MasterKeyEncryptedUserKey: string; MasterKeyEncryptedUserKey: string;
MasterKeyWrappedUserKey: string;
Salt: string; Salt: string;
Object: string;
} }
export interface UserDecryptionOptions { export interface UserDecryptionOptions {
HasMasterPassword: boolean; HasMasterPassword: boolean;
Object: string; Object: string;
MasterPasswordUnlock?: MasterPasswordUnlock; // Bitwarden Android 2026.1.x expects this to exist; missing it breaks unlock when the vault is empty.
MasterPasswordUnlock: MasterPasswordUnlock;
} }
// API Response types // API Response types
@@ -273,6 +276,21 @@ export interface SyncResponse {
domains: any; domains: any;
policies: any[]; policies: any[];
sends: any[]; sends: any[];
// PascalCase for desktop/browser clients
UserDecryptionOptions: UserDecryptionOptions | null; UserDecryptionOptions: UserDecryptionOptions | null;
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
userDecryption: {
masterPasswordUnlock: {
kdf: {
kdfType: number;
iterations: number;
memory: number | null;
parallelism: number | null;
};
masterKeyWrappedUserKey: string;
masterKeyEncryptedUserKey: string;
salt: string;
} | null;
} | null;
object: string; object: string;
} }
-7
View File
@@ -1,7 +0,0 @@
// Shared config related to JWT secret bootstrapping / safety checks.
// Keep this in one place so handlers don't duplicate the sample value.
// IMPORTANT:
// This is a *sample* secret value used in `.dev.vars.example`.
// If the runtime JWT_SECRET equals this value, we treat it as unsafe.
export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters';
-1
View File
@@ -2,7 +2,6 @@ name = "nodewarden-test"
main = "src/index.ts" main = "src/index.ts"
compatibility_date = "2024-01-01" compatibility_date = "2024-01-01"
# KV Namespace for storing vault data
# D1 Database for storing vault data # D1 Database for storing vault data
[[d1_databases]] [[d1_databases]]
binding = "DB" binding = "DB"