mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-21 13:20:13 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1e6ec8b8d | |||
| 3e56d05283 | |||
| 870149c771 | |||
| 9771df8777 | |||
| 0be3b91dd7 | |||
| 645a2f8e95 | |||
| f63b5d6cf4 | |||
| 081dc64093 |
@@ -6,6 +6,7 @@ node_modules/
|
|||||||
.dev.vars
|
.dev.vars
|
||||||
wrangler.my.toml
|
wrangler.my.toml
|
||||||
RELEASE_NOTES.md
|
RELEASE_NOTES.md
|
||||||
|
tests/selfcheck.ts
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
@@ -83,4 +83,7 @@ LGPL-3.0 License
|
|||||||
- [Bitwarden](https://bitwarden.com/) - 原始设计和客户端
|
- [Bitwarden](https://bitwarden.com/) - 原始设计和客户端
|
||||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务器实现参考
|
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务器实现参考
|
||||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
||||||
|
---
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||||
@@ -89,3 +89,7 @@ LGPL-3.0 License
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "0.2.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "0.2.0",
|
"version": "1.0.0",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.20260131.0",
|
"@cloudflare/workers-types": "^4.20260131.0",
|
||||||
|
|||||||
+2
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "0.2.0",
|
"version": "1.0.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",
|
||||||
@@ -9,8 +9,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "wrangler dev -c wrangler.toml",
|
"dev": "wrangler dev -c wrangler.toml",
|
||||||
"deploymy": "wrangler deploy -c wrangler.my.toml",
|
"deploymy": "wrangler deploy -c wrangler.my.toml",
|
||||||
"deploy": "wrangler deploy",
|
"deploy": "wrangler deploy"
|
||||||
"selfcheck": "npx tsx tests/selfcheck.ts"
|
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"bitwarden",
|
"bitwarden",
|
||||||
|
|||||||
@@ -99,6 +99,6 @@
|
|||||||
compatibility: {
|
compatibility: {
|
||||||
// Single source of truth for /config.version and /api/version.
|
// Single source of truth for /config.version and /api/version.
|
||||||
// /config.version 与 /api/version 的统一版本号来源。
|
// /config.version 与 /api/version 的统一版本号来源。
|
||||||
bitwardenServerVersion: '2025.12.0',
|
bitwardenServerVersion: '2026.1.0',
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
|||||||
: ciphers.filter(c => !c.deletedAt);
|
: 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
|
// Get attachments for all ciphers
|
||||||
const cipherResponses = [];
|
const cipherResponses = [];
|
||||||
|
|||||||
+58
-2
@@ -2,6 +2,7 @@ import { Env, Cipher, Folder, CipherType } from '../types';
|
|||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { errorResponse } from '../utils/response';
|
import { errorResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
// Bitwarden client import request format
|
// Bitwarden client import request format
|
||||||
interface CiphersImportRequest {
|
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
|
// POST /api/ciphers/import - Bitwarden client import endpoint
|
||||||
export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
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 folderRelationships = importData.folderRelationships || [];
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
const batchChunkSize = LIMITS.performance.bulkMoveChunkSize;
|
||||||
|
|
||||||
// Create folders and build index -> id mapping
|
// Create folders and build index -> id mapping
|
||||||
const folderIdMap = new Map<number, string>();
|
const folderIdMap = new Map<number, string>();
|
||||||
|
const folderRows: Folder[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < folders.length; i++) {
|
for (let i = 0; i < folders.length; i++) {
|
||||||
const folderId = generateUUID();
|
const folderId = generateUUID();
|
||||||
@@ -98,7 +112,19 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
updatedAt: now,
|
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
|
// Build cipher index -> folder id mapping from relationships
|
||||||
@@ -111,6 +137,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create ciphers
|
// Create ciphers
|
||||||
|
const cipherRows: Cipher[] = [];
|
||||||
for (let i = 0; i < ciphers.length; i++) {
|
for (let i = 0; i < ciphers.length; i++) {
|
||||||
const c = ciphers[i];
|
const c = ciphers[i];
|
||||||
const folderId = cipherFolderMap.get(i) || null;
|
const folderId = cipherFolderMap.get(i) || null;
|
||||||
@@ -181,7 +208,36 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
deletedAt: null,
|
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
|
// Update revision date
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
|
|
||||||
const ciphers = await storage.getAllCiphers(userId);
|
const ciphers = await storage.getAllCiphers(userId);
|
||||||
const folders = await storage.getAllFolders(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
|
// Build profile response
|
||||||
const profile: ProfileResponse = {
|
const profile: ProfileResponse = {
|
||||||
|
|||||||
+40
-2
@@ -471,10 +471,48 @@ export class StorageService {
|
|||||||
const grouped = new Map<string, Attachment[]>();
|
const grouped = new Map<string, Attachment[]>();
|
||||||
if (cipherIds.length === 0) return grouped;
|
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
|
const res = await this.db
|
||||||
.prepare(`SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id IN (${placeholders})`)
|
.prepare(`SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id IN (${placeholders})`)
|
||||||
.bind(...cipherIds)
|
.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 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>();
|
.all<any>();
|
||||||
|
|
||||||
for (const row of (res.results || [])) {
|
for (const row of (res.results || [])) {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--bg: #f3f4f6;
|
--grid-line: rgba(170, 170, 170, 0.34);
|
||||||
|
--grid-size: 30px;
|
||||||
--card: #ffffff;
|
--card: #ffffff;
|
||||||
--border: #d0d5dd;
|
--border: #d0d5dd;
|
||||||
--text: #101828;
|
--text: #101828;
|
||||||
@@ -33,7 +34,12 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
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);
|
color: var(--text);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -48,7 +54,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: 0px 0px 20px 10px rgba(16, 24, 40, 0.08);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -242,9 +248,9 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
height: 46px;
|
height: 46px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px solid #d5dae1;
|
border: 1px solid #c6ccd5;
|
||||||
background: #ffffff;
|
background: #f6f7f9;
|
||||||
color: #111418;
|
color: #1d2939;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -253,13 +259,39 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
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 {
|
.btn.primary {
|
||||||
border-color: #111418;
|
border-color: #111418;
|
||||||
background: #111418;
|
background: #111418;
|
||||||
color: #ffffff;
|
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 { 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-tabs { display: inline-flex; border: 1px solid #d5dae1; border-radius: 12px; overflow: hidden; }
|
||||||
.mode-tab {
|
.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 { display: flex; align-items: center; gap: 8px; width: 132px; }
|
||||||
.flow-actions .btn { width: 120px; padding: 0; }
|
.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 {
|
.dots {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user