mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
Improve API response formatting and structure in handlers
This commit is contained in:
@@ -27,9 +27,9 @@ A **Bitwarden-compatible** server that runs on **Cloudflare Workers**.
|
|||||||
|
|
||||||
## Tested clients / platforms
|
## Tested clients / platforms
|
||||||
|
|
||||||
- ✅ Windows desktop client(v2026.1.0)
|
- ✅ Windows desktop client (v2026.1.0)
|
||||||
- ✅ Android app (v2026.1.0)
|
- ✅ Android app (v2026.1.0)
|
||||||
- ✅ Browser extension(v2026.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
|
||||||
|
|
||||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden/tree/d1-test)
|
[](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
@@ -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 D1(SQLite)
|
||||||
- **文件存储**:Cloudflare R2
|
- **文件存储**:Cloudflare R2
|
||||||
- **开发语言**:TypeScript
|
- **开发语言**:TypeScript
|
||||||
- **加密算法**:客户端 AES-256-CBC,JWT 使用 HS256
|
- **加密算法**:客户端 AES-256-CBC,JWT 使用 HS256
|
||||||
|
|||||||
+5
-5
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user