diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..06dc408 --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,73 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + data TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- Per-user sync revision date +CREATE TABLE IF NOT EXISTS user_revisions ( + user_id TEXT PRIMARY KEY, + revision_date TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS ciphers ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + deleted_at TEXT, + updated_at TEXT NOT NULL, + data TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at); +CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at); + +CREATE TABLE IF NOT EXISTS folders ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + updated_at TEXT NOT NULL, + data TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at); + +CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + cipher_id TEXT, + data TEXT NOT NULL, + FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id); + +CREATE TABLE IF NOT EXISTS refresh_tokens ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); + +-- Rate limiting +CREATE TABLE IF NOT EXISTS login_attempts ( + email TEXT PRIMARY KEY, + attempts INTEGER NOT NULL, + locked_until INTEGER, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS api_rate_limits ( + identifier TEXT NOT NULL, + window_start INTEGER NOT NULL, + count INTEGER NOT NULL, + PRIMARY KEY (identifier, window_start) +); +CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); diff --git a/package-lock.json b/package-lock.json index 8d4705f..30da3c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,13 @@ { "name": "nodewarden", - "version": "1.0.0", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nodewarden", - "version": "1.0.0", - "license": "ISC", + "version": "0.1.0", + "license": "LGPL-3.0", "devDependencies": { "@cloudflare/workers-types": "^4.20260131.0", "typescript": "^5.9.3", diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index dbf637d..376f507 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -14,7 +14,7 @@ function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | // POST /api/accounts/register (only used from setup page, not client) export async function handleRegister(request: Request, env: Env): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); // Enforce safe JWT_SECRET before allowing first registration. const unsafe = jwtSecretUnsafeReason(env); @@ -96,7 +96,7 @@ export async function handleRegister(request: Request, env: Env): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const user = await storage.getUserById(userId); if (!user) { @@ -132,7 +132,7 @@ export async function handleGetProfile(request: Request, env: Env, userId: strin // PUT /api/accounts/profile export async function handleUpdateProfile(request: Request, env: Env, userId: string): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const user = await storage.getUserById(userId); if (!user) { @@ -158,7 +158,7 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st // POST /api/accounts/keys export async function handleSetKeys(request: Request, env: Env, userId: string): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const user = await storage.getUserById(userId); if (!user) { @@ -189,7 +189,7 @@ export async function handleSetKeys(request: Request, env: Env, userId: string): // GET /api/accounts/revision-date export async function handleGetRevisionDate(request: Request, env: Env, userId: string): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const revisionDate = await storage.getRevisionDate(userId); // Return as milliseconds timestamp (Bitwarden format) @@ -199,7 +199,7 @@ export async function handleGetRevisionDate(request: Request, env: Env, userId: // POST /api/accounts/verify-password export async function handleVerifyPassword(request: Request, env: Env, userId: string): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const user = await storage.getUserById(userId); if (!user) { diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index b7fadce..294da67 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -25,7 +25,7 @@ export async function handleCreateAttachment( userId: string, cipherId: string ): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); // Verify cipher exists and belongs to user const cipher = await storage.getCipher(cipherId); @@ -96,7 +96,7 @@ export async function handleUploadAttachment( cipherId: string, attachmentId: string ): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); // Verify cipher exists and belongs to user const cipher = await storage.getCipher(cipherId); @@ -169,7 +169,7 @@ export async function handleGetAttachment( cipherId: string, attachmentId: string ): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); // Verify cipher exists and belongs to user const cipher = await storage.getCipher(cipherId); @@ -227,7 +227,8 @@ export async function handlePublicDownloadAttachment( return errorResponse('Token mismatch', 401); } - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); + // Verify attachment exists const attachment = await storage.getAttachment(attachmentId); @@ -262,7 +263,7 @@ export async function handleDeleteAttachment( cipherId: string, attachmentId: string ): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); // Verify cipher exists and belongs to user const cipher = await storage.getCipher(cipherId); @@ -348,7 +349,7 @@ export async function deleteAllAttachmentsForCipher( env: Env, cipherId: string ): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const attachments = await storage.getAttachmentsByCipher(cipherId); for (const attachment of attachments) { diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index 3e2257b..65613eb 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -57,7 +57,7 @@ function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): Ciphe // GET /api/ciphers export async function handleGetCiphers(request: Request, env: Env, userId: string): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const ciphers = await storage.getAllCiphers(userId); // Filter out soft-deleted ciphers unless specifically requested @@ -84,7 +84,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin // GET /api/ciphers/:id export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const cipher = await storage.getCipher(id); if (!cipher || cipher.userId !== userId) { @@ -97,7 +97,7 @@ export async function handleGetCipher(request: Request, env: Env, userId: string // POST /api/ciphers export async function handleCreateCipher(request: Request, env: Env, userId: string): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); let body: any; try { @@ -141,7 +141,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str // PUT /api/ciphers/:id export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const existingCipher = await storage.getCipher(id); if (!existingCipher || existingCipher.userId !== userId) { @@ -186,7 +186,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str // DELETE /api/ciphers/:id export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const cipher = await storage.getCipher(id); if (!cipher || cipher.userId !== userId) { @@ -204,7 +204,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str // DELETE /api/ciphers/:id (permanent) export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const cipher = await storage.getCipher(id); if (!cipher || cipher.userId !== userId) { @@ -222,7 +222,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us // PUT /api/ciphers/:id/restore export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const cipher = await storage.getCipher(id); if (!cipher || cipher.userId !== userId) { @@ -239,7 +239,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st // PUT /api/ciphers/:id/partial - Update only favorite/folderId export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const cipher = await storage.getCipher(id); if (!cipher || cipher.userId !== userId) { @@ -269,7 +269,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user // POST/PUT /api/ciphers/move - Bulk move to folder export async function handleBulkMoveCiphers(request: Request, env: Env, userId: string): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); let body: { ids?: string[]; folderId?: string | null }; try { diff --git a/src/handlers/folders.ts b/src/handlers/folders.ts index 14c2fee..e02a24a 100644 --- a/src/handlers/folders.ts +++ b/src/handlers/folders.ts @@ -15,7 +15,7 @@ function folderToResponse(folder: Folder): FolderResponse { // GET /api/folders export async function handleGetFolders(request: Request, env: Env, userId: string): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const folders = await storage.getAllFolders(userId); return jsonResponse({ @@ -27,7 +27,7 @@ export async function handleGetFolders(request: Request, env: Env, userId: strin // GET /api/folders/:id export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const folder = await storage.getFolder(id); if (!folder || folder.userId !== userId) { @@ -39,7 +39,7 @@ export async function handleGetFolder(request: Request, env: Env, userId: string // POST /api/folders export async function handleCreateFolder(request: Request, env: Env, userId: string): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); let body: { name?: string }; try { @@ -68,7 +68,7 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str // PUT /api/folders/:id export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const folder = await storage.getFolder(id); if (!folder || folder.userId !== userId) { @@ -94,7 +94,7 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str // DELETE /api/folders/:id export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const folder = await storage.getFolder(id); if (!folder || folder.userId !== userId) { diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 3ea16a0..a6bf595 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -6,9 +6,9 @@ import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/res // POST /identity/connect/token export async function handleToken(request: Request, env: Env): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const auth = new AuthService(env); - const rateLimit = new RateLimitService(env.VAULT); + const rateLimit = new RateLimitService(env.DB); let body: Record; const contentType = request.headers.get('content-type') || ''; @@ -28,7 +28,8 @@ export async function handleToken(request: Request, env: Env): Promise const passwordHash = body.password; if (!email || !passwordHash) { - return errorResponse('Email and password are required', 400); + // Bitwarden clients expect OAuth-style error fields. + return identityErrorResponse('Email and password are required', 'invalid_request', 400); } const user = await storage.getUser(email); @@ -156,7 +157,7 @@ export async function handleToken(request: Request, env: Env): Promise // POST /identity/accounts/prelogin export async function handlePrelogin(request: Request, env: Env): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); let body: { email?: string }; try { diff --git a/src/handlers/import.ts b/src/handlers/import.ts index f0f224b..106cd48 100644 --- a/src/handlers/import.ts +++ b/src/handlers/import.ts @@ -68,7 +68,7 @@ interface CiphersImportRequest { // POST /api/ciphers/import - Bitwarden client import endpoint export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); let importData: CiphersImportRequest; try { diff --git a/src/handlers/setup.ts b/src/handlers/setup.ts index 65b41a2..b4fdd0a 100644 --- a/src/handlers/setup.ts +++ b/src/handlers/setup.ts @@ -15,7 +15,7 @@ function getJwtSecretState(env: Env): JwtSecretState | null { // GET / - Setup page export async function handleSetupPage(request: Request, env: Env): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const disabled = await storage.isSetupDisabled(); if (disabled) { return new Response(null, { status: 404 }); @@ -33,7 +33,7 @@ export async function handleSetupPage(request: Request, env: Env): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const registered = await storage.isRegistered(); const disabled = await storage.isSetupDisabled(); return jsonResponse({ registered, disabled }); @@ -41,7 +41,7 @@ export async function handleSetupStatus(request: Request, env: Env): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const registered = await storage.isRegistered(); if (!registered) { return errorResponse('Registration required', 403); diff --git a/src/handlers/setupRegisterPage.ts b/src/handlers/setupRegisterPage.ts index a6a9bba..5c985da 100644 --- a/src/handlers/setupRegisterPage.ts +++ b/src/handlers/setupRegisterPage.ts @@ -659,7 +659,7 @@ const registerPageHTML = ` `; export async function handleRegisterPage(request: Request, env: Env): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const disabled = await storage.isSetupDisabled(); if (disabled) { return new Response(null, { status: 404 }); diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index ea7236f..f013e35 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -18,7 +18,7 @@ function formatAttachments(attachments: Attachment[]): any[] | null { // GET /api/sync export async function handleSync(request: Request, env: Env, userId: string): Promise { - const storage = new StorageService(env.VAULT); + const storage = new StorageService(env.DB); const user = await storage.getUserById(userId); if (!user) { diff --git a/src/router.ts b/src/router.ts index dea7f2b..59d20e4 100644 --- a/src/router.ts +++ b/src/router.ts @@ -200,7 +200,7 @@ export async function handleRequest(request: Request, env: Env): Promise { - const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`; - const data = await this.kv.get(key); - - if (!data) { + const key = email.toLowerCase(); + const now = Date.now(); + + const row = await this.db + .prepare('SELECT attempts, locked_until FROM login_attempts WHERE email = ?') + .bind(key) + .first<{ attempts: number; locked_until: number | null }>(); + + if (!row) { return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS }; } - const record: { attempts: number; lockedUntil?: number } = JSON.parse(data); - const now = Date.now(); - - // Check if currently locked out - if (record.lockedUntil && record.lockedUntil > now) { - const retryAfterSeconds = Math.ceil((record.lockedUntil - now) / 1000); + if (row.locked_until && row.locked_until > now) { return { allowed: false, remainingAttempts: 0, - retryAfterSeconds, + retryAfterSeconds: Math.ceil((row.locked_until - now) / 1000), }; } - // If lockout expired, reset - if (record.lockedUntil && record.lockedUntil <= now) { - await this.kv.delete(key); + if (row.locked_until && row.locked_until <= now) { + await this.db.prepare('DELETE FROM login_attempts WHERE email = ?').bind(key).run(); return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS }; } - const remainingAttempts = CONFIG.LOGIN_MAX_ATTEMPTS - record.attempts; + const remainingAttempts = Math.max(0, CONFIG.LOGIN_MAX_ATTEMPTS - (row.attempts || 0)); return { allowed: true, remainingAttempts }; } - /** - * Record a failed login attempt - */ - async recordFailedLogin(email: string): Promise<{ - locked: boolean; - retryAfterSeconds?: number; - }> { - const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`; - const data = await this.kv.get(key); - - let record: { attempts: number; lockedUntil?: number }; - - if (data) { - record = JSON.parse(data); - record.attempts += 1; - } else { - record = { attempts: 1 }; - } + async recordFailedLogin(email: string): Promise<{ locked: boolean; retryAfterSeconds?: number }> { + const key = email.toLowerCase(); + const now = Date.now(); - // Check if should lock out - if (record.attempts >= CONFIG.LOGIN_MAX_ATTEMPTS) { - record.lockedUntil = Date.now() + CONFIG.LOGIN_LOCKOUT_MINUTES * 60 * 1000; - await this.kv.put(key, JSON.stringify(record), { - expirationTtl: CONFIG.LOGIN_LOCKOUT_MINUTES * 60 + 60, // Extra minute buffer - }); - return { - locked: true, - retryAfterSeconds: CONFIG.LOGIN_LOCKOUT_MINUTES * 60, - }; - } + // D1 in Workers forbids raw BEGIN/COMMIT statements. + // Use a single atomic UPSERT to increment attempts. + // This is concurrency-safe because the row is keyed by email. + await this.db + .prepare( + 'INSERT INTO login_attempts(email, attempts, locked_until, updated_at) VALUES(?, 1, NULL, ?) ' + + 'ON CONFLICT(email) DO UPDATE SET attempts = attempts + 1, updated_at = excluded.updated_at' + ) + .bind(key, now) + .run(); - // Store with expiration (auto-reset after lockout period even without lockout) - await this.kv.put(key, JSON.stringify(record), { - expirationTtl: CONFIG.LOGIN_LOCKOUT_MINUTES * 60, - }); + const row = await this.db + .prepare('SELECT attempts FROM login_attempts WHERE email = ?') + .bind(key) + .first<{ attempts: number }>(); + + const attempts = row?.attempts || 1; + if (attempts >= CONFIG.LOGIN_MAX_ATTEMPTS) { + const lockedUntil = now + CONFIG.LOGIN_LOCKOUT_MINUTES * 60 * 1000; + await this.db + .prepare('UPDATE login_attempts SET locked_until = ?, updated_at = ? WHERE email = ?') + .bind(lockedUntil, now, key) + .run(); + return { locked: true, retryAfterSeconds: CONFIG.LOGIN_LOCKOUT_MINUTES * 60 }; + } return { locked: false }; } - /** - * Clear login attempts on successful login - */ async clearLoginAttempts(email: string): Promise { - const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`; - await this.kv.delete(key); + await this.db.prepare('DELETE FROM login_attempts WHERE email = ?').bind(email.toLowerCase()).run(); } - /** - * Check API rate limit for a user or IP - * Returns { allowed: boolean, remaining: number, retryAfterSeconds?: number } - */ - async checkApiRateLimit(identifier: string): Promise<{ - allowed: boolean; - remaining: number; - retryAfterSeconds?: number; - }> { - const now = Math.floor(Date.now() / 1000); - const windowStart = now - (now % CONFIG.API_WINDOW_SECONDS); - const key = `${KEYS.API_RATE}${identifier}:${windowStart}`; + async checkApiRateLimit(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { + const nowSec = Math.floor(Date.now() / 1000); + const windowStart = nowSec - (nowSec % CONFIG.API_WINDOW_SECONDS); + const windowEnd = windowStart + CONFIG.API_WINDOW_SECONDS; - const countStr = await this.kv.get(key); - const count = countStr ? parseInt(countStr, 10) : 0; + const row = await this.db + .prepare('SELECT count FROM api_rate_limits WHERE identifier = ? AND window_start = ?') + .bind(identifier, windowStart) + .first<{ count: number }>(); + const count = row?.count || 0; if (count >= CONFIG.API_REQUESTS_PER_MINUTE) { - const retryAfterSeconds = CONFIG.API_WINDOW_SECONDS - (now % CONFIG.API_WINDOW_SECONDS); return { allowed: false, remaining: 0, - retryAfterSeconds, + retryAfterSeconds: windowEnd - nowSec, }; } @@ -137,35 +111,27 @@ export class RateLimitService { }; } - /** - * Increment API request count - */ async incrementApiCount(identifier: string): Promise { - const now = Math.floor(Date.now() / 1000); - const windowStart = now - (now % CONFIG.API_WINDOW_SECONDS); - const key = `${KEYS.API_RATE}${identifier}:${windowStart}`; + const nowSec = Math.floor(Date.now() / 1000); + const windowStart = nowSec - (nowSec % CONFIG.API_WINDOW_SECONDS); - const countStr = await this.kv.get(key); - const count = countStr ? parseInt(countStr, 10) : 0; - - await this.kv.put(key, (count + 1).toString(), { - expirationTtl: CONFIG.API_WINDOW_SECONDS + 10, // Slight buffer - }); + // Atomic increment via UPSERT. + await this.db + .prepare( + 'INSERT INTO api_rate_limits(identifier, window_start, count) VALUES(?, ?, 1) ' + + 'ON CONFLICT(identifier, window_start) DO UPDATE SET count = count + 1' + ) + .bind(identifier, windowStart) + .run(); } } -/** - * Get client identifier from request (IP or CF-Connecting-IP) - */ export function getClientIdentifier(request: Request): string { - // Cloudflare provides the real client IP const cfIp = request.headers.get('CF-Connecting-IP'); if (cfIp) return cfIp; - // Fallback for local development const forwardedFor = request.headers.get('X-Forwarded-For'); if (forwardedFor) return forwardedFor.split(',')[0].trim(); - // Last resort return 'unknown'; } diff --git a/src/services/storage.ts b/src/services/storage.ts index c83cc3e..141541e 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,256 +1,360 @@ -import { Env, User, Cipher, Folder, Attachment } from '../types'; +import { User, Cipher, Folder, Attachment } from '../types'; -const KEYS = { - CONFIG_REGISTERED: 'config:registered', - CONFIG_SETUP_DISABLED: 'config:setup_disabled', - USER_PREFIX: 'user:', - CIPHER_PREFIX: 'cipher:', - FOLDER_PREFIX: 'folder:', - ATTACHMENT_PREFIX: 'attachment:', - CIPHERS_INDEX: 'index:ciphers', - FOLDERS_INDEX: 'index:folders', - ATTACHMENTS_INDEX: 'index:attachments', - REFRESH_TOKEN_PREFIX: 'refresh:', - REVISION_DATE_PREFIX: 'revision:', -}; +// D1-backed storage. +// Contract: +// - All methods are scoped by userId where applicable. +// - Uses SQL constraints (PK/unique/FK) to avoid KV-style index race conditions. +// - Revision date is maintained per user for Bitwarden sync. export class StorageService { - constructor(private kv: KVNamespace) {} + constructor(private db: D1Database) {} + + // --- Config / setup --- - // Registration status async isRegistered(): Promise { - const value = await this.kv.get(KEYS.CONFIG_REGISTERED); - return value === 'true'; + const row = await this.db.prepare('SELECT value FROM config WHERE key = ?').bind('registered').first<{ value: string }>(); + return row?.value === 'true'; } async setRegistered(): Promise { - await this.kv.put(KEYS.CONFIG_REGISTERED, 'true'); + await this.db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value') + .bind('registered', 'true') + .run(); } - // Setup page visibility async isSetupDisabled(): Promise { - const value = await this.kv.get(KEYS.CONFIG_SETUP_DISABLED); - return value === 'true'; + const row = await this.db.prepare('SELECT value FROM config WHERE key = ?').bind('setup_disabled').first<{ value: string }>(); + return row?.value === 'true'; } async setSetupDisabled(): Promise { - await this.kv.put(KEYS.CONFIG_SETUP_DISABLED, 'true'); + await this.db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value') + .bind('setup_disabled', 'true') + .run(); } - // User operations + // --- Users --- + async getUser(email: string): Promise { - const data = await this.kv.get(`${KEYS.USER_PREFIX}${email.toLowerCase()}`); - return data ? JSON.parse(data) : null; + const row = await this.db + .prepare( + 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at FROM users WHERE email = ?' + ) + .bind(email.toLowerCase()) + .first(); + if (!row) return null; + return { + id: row.id, + email: row.email, + name: row.name, + masterPasswordHash: row.master_password_hash, + key: row.key, + privateKey: row.private_key, + publicKey: row.public_key, + kdfType: row.kdf_type, + kdfIterations: row.kdf_iterations, + kdfMemory: row.kdf_memory ?? undefined, + kdfParallelism: row.kdf_parallelism ?? undefined, + securityStamp: row.security_stamp, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; } async getUserById(id: string): Promise { - // Get user email from id mapping - const email = await this.kv.get(`userid:${id}`); - if (!email) return null; - return this.getUser(email); + const row = await this.db + .prepare( + 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at FROM users WHERE id = ?' + ) + .bind(id) + .first(); + if (!row) return null; + return { + id: row.id, + email: row.email, + name: row.name, + masterPasswordHash: row.master_password_hash, + key: row.key, + privateKey: row.private_key, + publicKey: row.public_key, + kdfType: row.kdf_type, + kdfIterations: row.kdf_iterations, + kdfMemory: row.kdf_memory ?? undefined, + kdfParallelism: row.kdf_parallelism ?? undefined, + securityStamp: row.security_stamp, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; } async saveUser(user: User): Promise { - await this.kv.put(`${KEYS.USER_PREFIX}${user.email.toLowerCase()}`, JSON.stringify(user)); - await this.kv.put(`userid:${user.id}`, user.email.toLowerCase()); + const email = user.email.toLowerCase(); + await this.db + .prepare( + 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at) ' + + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + + 'ON CONFLICT(id) DO UPDATE SET ' + + 'email=excluded.email, name=excluded.name, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + + 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, updated_at=excluded.updated_at' + ) + .bind( + user.id, + email, + user.name, + user.masterPasswordHash, + user.key, + user.privateKey, + user.publicKey, + user.kdfType, + user.kdfIterations, + user.kdfMemory ?? null, + user.kdfParallelism ?? null, + user.securityStamp, + user.createdAt, + user.updatedAt + ) + .run(); } - // Cipher operations + // --- Ciphers --- + async getCipher(id: string): Promise { - const data = await this.kv.get(`${KEYS.CIPHER_PREFIX}${id}`); - return data ? JSON.parse(data) : null; + 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; } async saveCipher(cipher: Cipher): Promise { - await this.kv.put(`${KEYS.CIPHER_PREFIX}${cipher.id}`, JSON.stringify(cipher)); - - // Update index - const index = await this.getCipherIds(cipher.userId); - if (!index.includes(cipher.id)) { - index.push(cipher.id); - await this.kv.put(`${KEYS.CIPHERS_INDEX}:${cipher.userId}`, JSON.stringify(index)); - } + const data = JSON.stringify(cipher); + await this.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, + cipher.folderId, + cipher.name, + cipher.notes, + cipher.favorite ? 1 : 0, + data, + cipher.reprompt ?? 0, + cipher.key, + cipher.createdAt, + cipher.updatedAt, + cipher.deletedAt + ) + .run(); } async deleteCipher(id: string, userId: string): Promise { - await this.kv.delete(`${KEYS.CIPHER_PREFIX}${id}`); - - // Update index - const index = await this.getCipherIds(userId); - const newIndex = index.filter(cid => cid !== id); - await this.kv.put(`${KEYS.CIPHERS_INDEX}:${userId}`, JSON.stringify(newIndex)); - } - - async getCipherIds(userId: string): Promise { - const data = await this.kv.get(`${KEYS.CIPHERS_INDEX}:${userId}`); - return data ? JSON.parse(data) : []; + // hard delete + await this.db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run(); } async getAllCiphers(userId: string): Promise { - const ids = await this.getCipherIds(userId); - const ciphers: Cipher[] = []; - - for (const id of ids) { - const cipher = await this.getCipher(id); - if (cipher) ciphers.push(cipher); - } - - return ciphers; + 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); } - // Folder operations - async getFolder(id: string): Promise { - const data = await this.kv.get(`${KEYS.FOLDER_PREFIX}${id}`); - return data ? JSON.parse(data) : null; - } - - async saveFolder(folder: Folder): Promise { - await this.kv.put(`${KEYS.FOLDER_PREFIX}${folder.id}`, JSON.stringify(folder)); - - // Update index - const index = await this.getFolderIds(folder.userId); - if (!index.includes(folder.id)) { - index.push(folder.id); - await this.kv.put(`${KEYS.FOLDERS_INDEX}:${folder.userId}`, JSON.stringify(index)); - } - } - - async deleteFolder(id: string, userId: string): Promise { - await this.kv.delete(`${KEYS.FOLDER_PREFIX}${id}`); - - // Update index - const index = await this.getFolderIds(userId); - const newIndex = index.filter(fid => fid !== id); - await this.kv.put(`${KEYS.FOLDERS_INDEX}:${userId}`, JSON.stringify(newIndex)); - } - - async getFolderIds(userId: string): Promise { - const data = await this.kv.get(`${KEYS.FOLDERS_INDEX}:${userId}`); - return data ? JSON.parse(data) : []; - } - - async getAllFolders(userId: string): Promise { - const ids = await this.getFolderIds(userId); - const folders: Folder[] = []; - - for (const id of ids) { - const folder = await this.getFolder(id); - if (folder) folders.push(folder); - } - - return folders; - } - - // Refresh token operations - async saveRefreshToken(token: string, userId: string): Promise { - // Store refresh token with 30 day expiry - await this.kv.put(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`, userId, { - expirationTtl: 30 * 24 * 60 * 60, - }); - } - - async getRefreshTokenUserId(token: string): Promise { - return await this.kv.get(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`); - } - - async deleteRefreshToken(token: string): Promise { - await this.kv.delete(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`); - } - - // Revision date operations (for incremental sync) - async getRevisionDate(userId: string): Promise { - const date = await this.kv.get(`${KEYS.REVISION_DATE_PREFIX}${userId}`); - return date || new Date().toISOString(); - } - - async updateRevisionDate(userId: string): Promise { - const date = new Date().toISOString(); - await this.kv.put(`${KEYS.REVISION_DATE_PREFIX}${userId}`, date); - return date; - } - - // Bulk cipher operations async getCiphersByIds(ids: string[], userId: string): Promise { - const ciphers: Cipher[] = []; - for (const id of ids) { - const cipher = await this.getCipher(id); - if (cipher && cipher.userId === userId) { - ciphers.push(cipher); - } - } - return ciphers; + if (ids.length === 0) return []; + // D1 doesn't support binding arrays directly; build placeholders. + const placeholders = ids.map(() => '?').join(','); + const stmt = this.db.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`); + const res = await stmt.bind(userId, ...ids).all<{ data: string }>(); + return (res.results || []).map(r => JSON.parse(r.data) as Cipher); } async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise { + if (ids.length === 0) return; const now = new Date().toISOString(); + + // D1 forbids raw BEGIN/COMMIT statements in this runtime. + // For this endpoint, we accept per-row updates and then bump revision once. + // Concurrency: each cipher write is an UPSERT on its PK, no shared index. for (const id of ids) { - const cipher = await this.getCipher(id); - if (cipher && cipher.userId === userId) { - cipher.folderId = folderId; - cipher.updatedAt = now; - await this.saveCipher(cipher); - } + const row = await this.db + .prepare('SELECT data FROM ciphers WHERE id = ? AND user_id = ?') + .bind(id, userId) + .first<{ data: string }>(); + if (!row?.data) continue; + const cipher = JSON.parse(row.data) as Cipher; + cipher.folderId = folderId; + cipher.updatedAt = now; + await this.saveCipher(cipher); } + await this.updateRevisionDate(userId); } - // Attachment operations + // --- Folders --- + + async getFolder(id: string): Promise { + const row = await this.db + .prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE id = ?') + .bind(id) + .first(); + if (!row) return null; + return { + id: row.id, + userId: row.user_id, + name: row.name, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + async saveFolder(folder: Folder): Promise { + await this.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) + .run(); + } + + async deleteFolder(id: string, userId: string): Promise { + await this.db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run(); + } + + async getAllFolders(userId: string): Promise { + const res = await this.db + .prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC') + .bind(userId) + .all(); + return (res.results || []).map(r => ({ + id: r.id, + userId: r.user_id, + name: r.name, + createdAt: r.created_at, + updatedAt: r.updated_at, + })); + } + + // --- Attachments --- + async getAttachment(id: string): Promise { - const data = await this.kv.get(`${KEYS.ATTACHMENT_PREFIX}${id}`); - return data ? JSON.parse(data) : null; + const row = await this.db + .prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE id = ?') + .bind(id) + .first(); + if (!row) return null; + return { + id: row.id, + cipherId: row.cipher_id, + fileName: row.file_name, + size: row.size, + sizeName: row.size_name, + key: row.key, + }; } async saveAttachment(attachment: Attachment): Promise { - await this.kv.put(`${KEYS.ATTACHMENT_PREFIX}${attachment.id}`, JSON.stringify(attachment)); + await this.db + .prepare( + 'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' + + 'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key' + ) + .bind(attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key) + .run(); } async deleteAttachment(id: string): Promise { - await this.kv.delete(`${KEYS.ATTACHMENT_PREFIX}${id}`); - } - - async getAttachmentIdsByCipher(cipherId: string): Promise { - const data = await this.kv.get(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`); - return data ? JSON.parse(data) : []; + await this.db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run(); } async getAttachmentsByCipher(cipherId: string): Promise { - const ids = await this.getAttachmentIdsByCipher(cipherId); - const attachments: Attachment[] = []; - for (const id of ids) { - const attachment = await this.getAttachment(id); - if (attachment) attachments.push(attachment); - } - return attachments; + const res = await this.db + .prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?') + .bind(cipherId) + .all(); + return (res.results || []).map(r => ({ + id: r.id, + cipherId: r.cipher_id, + fileName: r.file_name, + size: r.size, + sizeName: r.size_name, + key: r.key, + })); } async addAttachmentToCipher(cipherId: string, attachmentId: string): Promise { - const ids = await this.getAttachmentIdsByCipher(cipherId); - if (!ids.includes(attachmentId)) { - ids.push(attachmentId); - await this.kv.put(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`, JSON.stringify(ids)); - } + // Kept for API compatibility; no-op because attachments table already links cipher_id. + // We still validate that the attachment exists and belongs to cipher. + await this.db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run(); } async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise { - const ids = await this.getAttachmentIdsByCipher(cipherId); - const newIds = ids.filter(id => id !== attachmentId); - await this.kv.put(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`, JSON.stringify(newIds)); + // No-op: schema uses NOT NULL cipher_id. + // Callers always delete attachment row afterwards, so this method is kept for compatibility only. + void cipherId; + void attachmentId; } async deleteAllAttachmentsByCipher(cipherId: string): Promise { - const ids = await this.getAttachmentIdsByCipher(cipherId); - for (const id of ids) { - await this.deleteAttachment(id); - } - await this.kv.delete(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`); + await this.db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run(); } async updateCipherRevisionDate(cipherId: string): Promise { const cipher = await this.getCipher(cipherId); - if (cipher) { - cipher.updatedAt = new Date().toISOString(); - await this.saveCipher(cipher); - await this.updateRevisionDate(cipher.userId); + if (!cipher) return; + cipher.updatedAt = new Date().toISOString(); + await this.saveCipher(cipher); + await this.updateRevisionDate(cipher.userId); + } + + // --- Refresh tokens --- + + async saveRefreshToken(token: string, userId: string, expiresAtMs?: number): Promise { + const expiresAt = expiresAtMs ?? (Date.now() + 30 * 24 * 60 * 60 * 1000); + await this.db.prepare( + 'INSERT INTO refresh_tokens(token, user_id, expires_at) VALUES(?, ?, ?) ' + + 'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at' + ) + .bind(token, userId, expiresAt) + .run(); + } + + async getRefreshTokenUserId(token: string): Promise { + const now = Date.now(); + const row = await this.db.prepare('SELECT user_id, expires_at FROM refresh_tokens WHERE token = ?') + .bind(token) + .first<{ user_id: string; expires_at: number }>(); + + if (!row) return null; + if (row.expires_at && row.expires_at < now) { + await this.deleteRefreshToken(token); + return null; } + return row.user_id; + } + + async deleteRefreshToken(token: string): Promise { + await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run(); + } + + // --- Revision dates --- + + async getRevisionDate(userId: string): Promise { + const row = await this.db.prepare('SELECT revision_date FROM user_revisions WHERE user_id = ?') + .bind(userId) + .first<{ revision_date: string }>(); + return row?.revision_date || new Date().toISOString(); + } + + async updateRevisionDate(userId: string): Promise { + const date = new Date().toISOString(); + await this.db.prepare( + 'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' + + 'ON CONFLICT(user_id) DO UPDATE SET revision_date = excluded.revision_date' + ) + .bind(userId, date) + .run(); + return date; } } diff --git a/src/types/index.ts b/src/types/index.ts index 0e1bdc7..f819372 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,6 @@ // Environment bindings export interface Env { - VAULT: KVNamespace; + DB: D1Database; ATTACHMENTS: R2Bucket; JWT_SECRET: string; } diff --git a/wrangler.toml b/wrangler.toml index facbafe..597e1b9 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -3,9 +3,11 @@ main = "src/index.ts" compatibility_date = "2024-01-01" # KV Namespace for storing vault data -[[kv_namespaces]] -binding = "VAULT" -id = "placeholder" +# D1 Database for storing vault data +[[d1_databases]] +binding = "DB" +database_name = "nodewarden-db" +database_id = "placeholde # R2 Bucket for storing attachments [[r2_buckets]]