8 Commits

11 changed files with 165 additions and 19 deletions
+1
View File
@@ -6,6 +6,7 @@ node_modules/
.dev.vars
wrangler.my.toml
RELEASE_NOTES.md
tests/selfcheck.ts
# Build output
dist/
+3
View File
@@ -83,4 +83,7 @@ LGPL-3.0 License
- [Bitwarden](https://bitwarden.com/) - 原始设计和客户端
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务器实现参考
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
+4
View File
@@ -89,3 +89,7 @@ LGPL-3.0 License
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "nodewarden",
"version": "0.2.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nodewarden",
"version": "0.2.0",
"version": "1.0.0",
"license": "LGPL-3.0",
"devDependencies": {
"@cloudflare/workers-types": "^4.20260131.0",
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "nodewarden",
"version": "0.2.0",
"version": "1.0.0",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus",
"license": "LGPL-3.0",
@@ -9,8 +9,7 @@
"scripts": {
"dev": "wrangler dev -c wrangler.toml",
"deploymy": "wrangler deploy -c wrangler.my.toml",
"deploy": "wrangler deploy",
"selfcheck": "npx tsx tests/selfcheck.ts"
"deploy": "wrangler deploy"
},
"keywords": [
"bitwarden",
+1 -1
View File
@@ -99,6 +99,6 @@
compatibility: {
// Single source of truth for /config.version and /api/version.
// /config.version 与 /api/version 的统一版本号来源。
bitwardenServerVersion: '2025.12.0',
bitwardenServerVersion: '2026.1.0',
},
} as const;
+1 -1
View File
@@ -77,7 +77,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
: ciphers.filter(c => !c.deletedAt);
}
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(filteredCiphers.map(c => c.id));
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
// Get attachments for all ciphers
const cipherResponses = [];
+58 -2
View File
@@ -2,6 +2,7 @@ import { Env, Cipher, Folder, CipherType } from '../types';
import { StorageService } from '../services/storage';
import { errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits';
// Bitwarden client import request format
interface CiphersImportRequest {
@@ -66,6 +67,17 @@ interface CiphersImportRequest {
}>;
}
function bindNull(v: any): any {
return v === undefined ? null : v;
}
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
for (let i = 0; i < statements.length; i += chunkSize) {
const chunk = statements.slice(i, i + chunkSize);
await db.batch(chunk);
}
}
// POST /api/ciphers/import - Bitwarden client import endpoint
export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
@@ -82,9 +94,11 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
const folderRelationships = importData.folderRelationships || [];
const now = new Date().toISOString();
const batchChunkSize = LIMITS.performance.bulkMoveChunkSize;
// Create folders and build index -> id mapping
const folderIdMap = new Map<number, string>();
const folderRows: Folder[] = [];
for (let i = 0; i < folders.length; i++) {
const folderId = generateUUID();
@@ -98,7 +112,19 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
updatedAt: now,
};
await storage.saveFolder(folder);
folderRows.push(folder);
}
if (folderRows.length > 0) {
const folderStatements = folderRows.map(folder =>
env.DB
.prepare(
'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET user_id=excluded.user_id, name=excluded.name, updated_at=excluded.updated_at'
)
.bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt)
);
await runBatchInChunks(env.DB, folderStatements, batchChunkSize);
}
// Build cipher index -> folder id mapping from relationships
@@ -111,6 +137,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
}
// Create ciphers
const cipherRows: Cipher[] = [];
for (let i = 0; i < ciphers.length; i++) {
const c = ciphers[i];
const folderId = cipherFolderMap.get(i) || null;
@@ -181,7 +208,36 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
deletedAt: null,
};
await storage.saveCipher(cipher);
cipherRows.push(cipher);
}
if (cipherRows.length > 0) {
const cipherStatements = cipherRows.map(cipher => {
const data = JSON.stringify(cipher);
return env.DB
.prepare(
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' +
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at'
)
.bind(
cipher.id,
cipher.userId,
Number(cipher.type) || 1,
bindNull(cipher.folderId),
bindNull(cipher.name),
bindNull(cipher.notes),
cipher.favorite ? 1 : 0,
data,
bindNull(cipher.reprompt ?? 0),
bindNull(cipher.key),
cipher.createdAt,
cipher.updatedAt,
bindNull(cipher.deletedAt)
);
});
await runBatchInChunks(env.DB, cipherStatements, batchChunkSize);
}
// Update revision date
+1 -1
View File
@@ -60,7 +60,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
const ciphers = await storage.getAllCiphers(userId);
const folders = await storage.getAllFolders(userId);
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map(c => c.id));
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
// Build profile response
const profile: ProfileResponse = {
+41 -3
View File
@@ -471,10 +471,48 @@ export class StorageService {
const grouped = new Map<string, Attachment[]>();
if (cipherIds.length === 0) return grouped;
const placeholders = cipherIds.map(() => '?').join(',');
const uniqueCipherIds = [...new Set(cipherIds)];
const chunkSize = LIMITS.performance.bulkMoveChunkSize;
for (let i = 0; i < uniqueCipherIds.length; i += chunkSize) {
const chunk = uniqueCipherIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
const res = await this.db
.prepare(`SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id IN (${placeholders})`)
.bind(...chunk)
.all<any>();
for (const row of (res.results || [])) {
const item: Attachment = {
id: row.id,
cipherId: row.cipher_id,
fileName: row.file_name,
size: row.size,
sizeName: row.size_name,
key: row.key,
};
const list = grouped.get(item.cipherId);
if (list) {
list.push(item);
} else {
grouped.set(item.cipherId, [item]);
}
}
}
return grouped;
}
async getAttachmentsByUserId(userId: string): Promise<Map<string, Attachment[]>> {
const grouped = new Map<string, Attachment[]>();
const res = await this.db
.prepare(`SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id IN (${placeholders})`)
.bind(...cipherIds)
.prepare(
`SELECT a.id, a.cipher_id, a.file_name, a.size, a.size_name, a.key
FROM attachments a
INNER JOIN ciphers c ON c.id = a.cipher_id
WHERE c.user_id = ?`
)
.bind(userId)
.all<any>();
for (const row of (res.results || [])) {
+51 -6
View File
@@ -15,7 +15,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
<style>
:root {
color-scheme: light;
--bg: #f3f4f6;
--grid-line: rgba(170, 170, 170, 0.34);
--grid-size: 30px;
--card: #ffffff;
--border: #d0d5dd;
--text: #101828;
@@ -33,7 +34,12 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
background-color: var(--bg);
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: var(--grid-size) var(--grid-size);
background-position: -1px -1px;
color: var(--text);
display: flex;
align-items: center;
@@ -48,7 +54,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
border: 1px solid var(--border);
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow);
box-shadow: 0px 0px 20px 10px rgba(16, 24, 40, 0.08);
display: flex;
flex-direction: column;
position: relative;
@@ -242,9 +248,9 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
height: 46px;
padding: 0 16px;
border-radius: 14px;
border: 1px solid #d5dae1;
background: #ffffff;
color: #111418;
border: 1px solid #c6ccd5;
background: #f6f7f9;
color: #1d2939;
font-weight: 700;
font-size: 15px;
display: inline-flex;
@@ -253,13 +259,39 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
text-align: center;
cursor: pointer;
white-space: nowrap;
transition: transform 140ms ease, box-shadow 140ms ease, background-color 140ms ease, border-color 140ms ease;
}
.btn:hover {
background: #edf0f4;
border-color: #b8c0cc;
transform: translateY(-1px);
box-shadow: 0 8px 18px rgba(16, 24, 40, 0.08);
}
.btn:active {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(16, 24, 40, 0.08);
}
.btn.primary {
border-color: #111418;
background: #111418;
color: #ffffff;
}
.btn.primary:hover {
background: #1f242b;
border-color: #1f242b;
box-shadow: 0 10px 22px rgba(16, 24, 40, 0.22);
}
.btn.primary:active {
background: #151a20;
border-color: #151a20;
}
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
.btn:disabled:hover {
transform: none;
box-shadow: none;
background: inherit;
border-color: inherit;
}
.mode-tabs { display: inline-flex; border: 1px solid #d5dae1; border-radius: 12px; overflow: hidden; }
.mode-tab {
@@ -299,6 +331,19 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
.flow-actions { display: flex; align-items: center; gap: 8px; width: 132px; }
.flow-actions .btn { width: 120px; padding: 0; }
@media (max-width: 560px) {
.flow-bottom {
padding: 0;
gap: 10px;
}
.flow-actions {
width: calc(50% - 10px);
}
.flow-actions .btn {
width: 100%;
}
}
.dots {
display: inline-flex;
align-items: center;