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
|
||||
wrangler.my.toml
|
||||
RELEASE_NOTES.md
|
||||
tests/selfcheck.ts
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
@@ -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
|
||||
|
||||
[](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",
|
||||
"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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 || [])) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user