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
+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 { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
import { cipherToResponse } from './ciphers';
// Format file size to human readable
function formatSize(bytes: number): string {
@@ -80,7 +81,7 @@ export async function handleCreateAttachment(
attachmentId: attachmentId,
url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`,
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);
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)
export async function deleteAllAttachmentsForCipher(
env: Env,
+2 -2
View File
@@ -5,7 +5,7 @@ import { generateUUID } from '../utils/uuid';
import { deleteAllAttachmentsForCipher } from './attachments';
// Format attachments for API response
function formatAttachments(attachments: Attachment[]): any[] | null {
export function formatAttachments(attachments: Attachment[]): any[] | null {
if (attachments.length === 0) return null;
return attachments.map(a => ({
id: a.id,
@@ -19,7 +19,7 @@ function formatAttachments(attachments: Attachment[]): any[] | null {
}
// Convert internal cipher to API response format
function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse {
export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse {
return {
id: cipher.id,
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.updateRevisionDate(userId);
return jsonResponse(folderToResponse(folder), 200);
}
@@ -88,6 +89,7 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
folder.updatedAt = new Date().toISOString();
await storage.saveFolder(folder);
await storage.updateRevisionDate(userId);
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.updateRevisionDate(userId);
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;
if (!email || !passwordHash) {
// Bitwarden clients expect OAuth-style error fields.
return identityErrorResponse('Email and password are required', 'invalid_request', 400);
// Bitwarden clients expect OAuth-style error fields.
return identityErrorResponse('Email and password are required', 'invalid_request', 400);
}
const user = await storage.getUser(email);
@@ -93,7 +93,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
Parallelism: user.kdfParallelism || null,
},
MasterKeyEncryptedUserKey: user.key,
MasterKeyWrappedUserKey: user.key,
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,
},
MasterKeyEncryptedUserKey: user.key,
MasterKeyWrappedUserKey: user.key,
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="title">
<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 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 style="height: 14px"></div>
<h2 id="t_setup">初始化</h2>
<h2 id="t_setup">Setup</h2>
<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 { jsonResponse, errorResponse } from '../utils/response';
// 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',
}));
}
import { cipherToResponse } from './ciphers';
// GET /api/sync
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[] = [];
for (const cipher of ciphers) {
const attachments = await storage.getAttachmentsByCipher(cipher.id);
cipherResponses.push({
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,
});
};
cipherResponses.push(cipherToResponse(cipher, attachments));
}
// Build folder responses
const folderResponses: FolderResponse[] = folders.map(folder => ({
@@ -112,6 +67,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
},
policies: [],
sends: [],
// PascalCase for desktop/browser clients
UserDecryptionOptions: {
HasMasterPassword: true,
Object: 'userDecryptionOptions',
@@ -123,7 +79,23 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
Parallelism: user.kdfParallelism || null,
},
MasterKeyEncryptedUserKey: user.key,
MasterKeyWrappedUserKey: user.key,
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',