chore: switch storage to D1 (test branch)

This commit is contained in:
shuaiplus
2026-02-09 22:00:14 +08:00
parent 5fc2436552
commit d2ce2aea24
17 changed files with 480 additions and 333 deletions
+73
View File
@@ -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);
+3 -3
View File
@@ -1,13 +1,13 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.0.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nodewarden", "name": "nodewarden",
"version": "1.0.0", "version": "0.1.0",
"license": "ISC", "license": "LGPL-3.0",
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20260131.0", "@cloudflare/workers-types": "^4.20260131.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
+6 -6
View File
@@ -14,7 +14,7 @@ function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' |
// POST /api/accounts/register (only used from setup page, not client) // POST /api/accounts/register (only used from setup page, not client)
export async function handleRegister(request: Request, env: Env): Promise<Response> { export async function handleRegister(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
// Enforce safe JWT_SECRET before allowing first registration. // Enforce safe JWT_SECRET before allowing first registration.
const unsafe = jwtSecretUnsafeReason(env); const unsafe = jwtSecretUnsafeReason(env);
@@ -96,7 +96,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
// GET /api/accounts/profile // GET /api/accounts/profile
export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> { export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) { if (!user) {
@@ -132,7 +132,7 @@ export async function handleGetProfile(request: Request, env: Env, userId: strin
// PUT /api/accounts/profile // PUT /api/accounts/profile
export async function handleUpdateProfile(request: Request, env: Env, userId: string): Promise<Response> { export async function handleUpdateProfile(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) { if (!user) {
@@ -158,7 +158,7 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
// POST /api/accounts/keys // POST /api/accounts/keys
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> { export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) { if (!user) {
@@ -189,7 +189,7 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
// GET /api/accounts/revision-date // GET /api/accounts/revision-date
export async function handleGetRevisionDate(request: Request, env: Env, userId: string): Promise<Response> { export async function handleGetRevisionDate(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const revisionDate = await storage.getRevisionDate(userId); const revisionDate = await storage.getRevisionDate(userId);
// Return as milliseconds timestamp (Bitwarden format) // Return as milliseconds timestamp (Bitwarden format)
@@ -199,7 +199,7 @@ export async function handleGetRevisionDate(request: Request, env: Env, userId:
// POST /api/accounts/verify-password // POST /api/accounts/verify-password
export async function handleVerifyPassword(request: Request, env: Env, userId: string): Promise<Response> { export async function handleVerifyPassword(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) { if (!user) {
+7 -6
View File
@@ -25,7 +25,7 @@ export async function handleCreateAttachment(
userId: string, userId: string,
cipherId: string cipherId: string
): Promise<Response> { ): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user // Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipher(cipherId);
@@ -96,7 +96,7 @@ export async function handleUploadAttachment(
cipherId: string, cipherId: string,
attachmentId: string attachmentId: string
): Promise<Response> { ): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user // Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipher(cipherId);
@@ -169,7 +169,7 @@ export async function handleGetAttachment(
cipherId: string, cipherId: string,
attachmentId: string attachmentId: string
): Promise<Response> { ): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user // Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipher(cipherId);
@@ -227,7 +227,8 @@ export async function handlePublicDownloadAttachment(
return errorResponse('Token mismatch', 401); return errorResponse('Token mismatch', 401);
} }
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
// Verify attachment exists // Verify attachment exists
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachment(attachmentId);
@@ -262,7 +263,7 @@ export async function handleDeleteAttachment(
cipherId: string, cipherId: string,
attachmentId: string attachmentId: string
): Promise<Response> { ): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user // Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipher(cipherId);
@@ -348,7 +349,7 @@ export async function deleteAllAttachmentsForCipher(
env: Env, env: Env,
cipherId: string cipherId: string
): Promise<void> { ): Promise<void> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const attachments = await storage.getAttachmentsByCipher(cipherId); const attachments = await storage.getAttachmentsByCipher(cipherId);
for (const attachment of attachments) { for (const attachment of attachments) {
+9 -9
View File
@@ -57,7 +57,7 @@ function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): Ciphe
// GET /api/ciphers // GET /api/ciphers
export async function handleGetCiphers(request: Request, env: Env, userId: string): Promise<Response> { export async function handleGetCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const ciphers = await storage.getAllCiphers(userId); const ciphers = await storage.getAllCiphers(userId);
// Filter out soft-deleted ciphers unless specifically requested // 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 // GET /api/ciphers/:id
export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
@@ -97,7 +97,7 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
// POST /api/ciphers // POST /api/ciphers
export async function handleCreateCipher(request: Request, env: Env, userId: string): Promise<Response> { export async function handleCreateCipher(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
let body: any; let body: any;
try { try {
@@ -141,7 +141,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
// PUT /api/ciphers/:id // PUT /api/ciphers/:id
export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const existingCipher = await storage.getCipher(id); const existingCipher = await storage.getCipher(id);
if (!existingCipher || existingCipher.userId !== userId) { if (!existingCipher || existingCipher.userId !== userId) {
@@ -186,7 +186,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
// DELETE /api/ciphers/:id // DELETE /api/ciphers/:id
export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
@@ -204,7 +204,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
// DELETE /api/ciphers/:id (permanent) // DELETE /api/ciphers/:id (permanent)
export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
@@ -222,7 +222,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
// PUT /api/ciphers/:id/restore // PUT /api/ciphers/:id/restore
export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) { 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 // PUT /api/ciphers/:id/partial - Update only favorite/folderId
export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) { 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 // POST/PUT /api/ciphers/move - Bulk move to folder
export async function handleBulkMoveCiphers(request: Request, env: Env, userId: string): Promise<Response> { export async function handleBulkMoveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
let body: { ids?: string[]; folderId?: string | null }; let body: { ids?: string[]; folderId?: string | null };
try { try {
+5 -5
View File
@@ -15,7 +15,7 @@ function folderToResponse(folder: Folder): FolderResponse {
// GET /api/folders // GET /api/folders
export async function handleGetFolders(request: Request, env: Env, userId: string): Promise<Response> { export async function handleGetFolders(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const folders = await storage.getAllFolders(userId); const folders = await storage.getAllFolders(userId);
return jsonResponse({ return jsonResponse({
@@ -27,7 +27,7 @@ export async function handleGetFolders(request: Request, env: Env, userId: strin
// GET /api/folders/:id // GET /api/folders/:id
export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const folder = await storage.getFolder(id); const folder = await storage.getFolder(id);
if (!folder || folder.userId !== userId) { if (!folder || folder.userId !== userId) {
@@ -39,7 +39,7 @@ export async function handleGetFolder(request: Request, env: Env, userId: string
// POST /api/folders // POST /api/folders
export async function handleCreateFolder(request: Request, env: Env, userId: string): Promise<Response> { export async function handleCreateFolder(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
let body: { name?: string }; let body: { name?: string };
try { try {
@@ -68,7 +68,7 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
// PUT /api/folders/:id // PUT /api/folders/:id
export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const folder = await storage.getFolder(id); const folder = await storage.getFolder(id);
if (!folder || folder.userId !== userId) { if (!folder || folder.userId !== userId) {
@@ -94,7 +94,7 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
// DELETE /api/folders/:id // DELETE /api/folders/:id
export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const folder = await storage.getFolder(id); const folder = await storage.getFolder(id);
if (!folder || folder.userId !== userId) { if (!folder || folder.userId !== userId) {
+5 -4
View File
@@ -6,9 +6,9 @@ import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/res
// POST /identity/connect/token // POST /identity/connect/token
export async function handleToken(request: Request, env: Env): Promise<Response> { export async function handleToken(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const auth = new AuthService(env); const auth = new AuthService(env);
const rateLimit = new RateLimitService(env.VAULT); const rateLimit = new RateLimitService(env.DB);
let body: Record<string, string>; let body: Record<string, string>;
const contentType = request.headers.get('content-type') || ''; const contentType = request.headers.get('content-type') || '';
@@ -28,7 +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) {
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); const user = await storage.getUser(email);
@@ -156,7 +157,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// POST /identity/accounts/prelogin // POST /identity/accounts/prelogin
export async function handlePrelogin(request: Request, env: Env): Promise<Response> { export async function handlePrelogin(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
let body: { email?: string }; let body: { email?: string };
try { try {
+1 -1
View File
@@ -68,7 +68,7 @@ interface CiphersImportRequest {
// 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.VAULT); const storage = new StorageService(env.DB);
let importData: CiphersImportRequest; let importData: CiphersImportRequest;
try { try {
+3 -3
View File
@@ -15,7 +15,7 @@ function getJwtSecretState(env: Env): JwtSecretState | null {
// GET / - Setup page // GET / - Setup page
export async function handleSetupPage(request: Request, env: Env): Promise<Response> { export async function handleSetupPage(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const disabled = await storage.isSetupDisabled(); const disabled = await storage.isSetupDisabled();
if (disabled) { if (disabled) {
return new Response(null, { status: 404 }); return new Response(null, { status: 404 });
@@ -33,7 +33,7 @@ export async function handleSetupPage(request: Request, env: Env): Promise<Respo
// GET /setup/status // GET /setup/status
export async function handleSetupStatus(request: Request, env: Env): Promise<Response> { export async function handleSetupStatus(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const registered = await storage.isRegistered(); const registered = await storage.isRegistered();
const disabled = await storage.isSetupDisabled(); const disabled = await storage.isSetupDisabled();
return jsonResponse({ registered, disabled }); return jsonResponse({ registered, disabled });
@@ -41,7 +41,7 @@ export async function handleSetupStatus(request: Request, env: Env): Promise<Res
// POST /setup/disable // POST /setup/disable
export async function handleDisableSetup(request: Request, env: Env): Promise<Response> { export async function handleDisableSetup(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const registered = await storage.isRegistered(); const registered = await storage.isRegistered();
if (!registered) { if (!registered) {
return errorResponse('Registration required', 403); return errorResponse('Registration required', 403);
+1 -1
View File
@@ -659,7 +659,7 @@ const registerPageHTML = `<!DOCTYPE html>
</html>`; </html>`;
export async function handleRegisterPage(request: Request, env: Env): Promise<Response> { export async function handleRegisterPage(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const disabled = await storage.isSetupDisabled(); const disabled = await storage.isSetupDisabled();
if (disabled) { if (disabled) {
return new Response(null, { status: 404 }); return new Response(null, { status: 404 });
+1 -1
View File
@@ -18,7 +18,7 @@ function formatAttachments(attachments: Attachment[]): any[] | null {
// 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> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) { if (!user) {
+1 -1
View File
@@ -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.VAULT); 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);
+1 -1
View File
@@ -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.VAULT); this.storage = new StorageService(env.DB);
} }
// Verify password hash (compare with stored hash) // Verify password hash (compare with stored hash)
+69 -103
View File
@@ -1,133 +1,107 @@
import { Env } from '../types'; // D1-backed rate limiting.
// Notes:
// - Login attempts are tracked per email.
// - API rate is tracked per identifier per fixed window.
// Rate limit configuration // Rate limit configuration
const CONFIG = { const CONFIG = {
// Login attempt limits LOGIN_MAX_ATTEMPTS: 15,
LOGIN_MAX_ATTEMPTS: 15, // Max failed login attempts LOGIN_LOCKOUT_MINUTES: 5,
LOGIN_LOCKOUT_MINUTES: 5, // Lockout duration after max attempts
// API rate limits (per minute) API_REQUESTS_PER_MINUTE: 300,
API_REQUESTS_PER_MINUTE: 300, // General API rate limit API_WINDOW_SECONDS: 60,
API_WINDOW_SECONDS: 60, // Rate limit window
};
// KV key prefixes
const KEYS = {
LOGIN_ATTEMPTS: 'ratelimit:login:',
API_RATE: 'ratelimit:api:',
}; };
export class RateLimitService { export class RateLimitService {
constructor(private kv: KVNamespace) {} constructor(private db: D1Database) {}
/**
* Check and record login attempt
* Returns { allowed: boolean, remainingAttempts: number, retryAfterSeconds?: number }
*/
async checkLoginAttempt(email: string): Promise<{ async checkLoginAttempt(email: string): Promise<{
allowed: boolean; allowed: boolean;
remainingAttempts: number; remainingAttempts: number;
retryAfterSeconds?: number; retryAfterSeconds?: number;
}> { }> {
const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`; const key = email.toLowerCase();
const data = await this.kv.get(key); const now = Date.now();
if (!data) { 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 }; return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
} }
const record: { attempts: number; lockedUntil?: number } = JSON.parse(data); if (row.locked_until && row.locked_until > now) {
const now = Date.now();
// Check if currently locked out
if (record.lockedUntil && record.lockedUntil > now) {
const retryAfterSeconds = Math.ceil((record.lockedUntil - now) / 1000);
return { return {
allowed: false, allowed: false,
remainingAttempts: 0, remainingAttempts: 0,
retryAfterSeconds, retryAfterSeconds: Math.ceil((row.locked_until - now) / 1000),
}; };
} }
// If lockout expired, reset if (row.locked_until && row.locked_until <= now) {
if (record.lockedUntil && record.lockedUntil <= now) { await this.db.prepare('DELETE FROM login_attempts WHERE email = ?').bind(key).run();
await this.kv.delete(key);
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS }; 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 }; return { allowed: true, remainingAttempts };
} }
/** async recordFailedLogin(email: string): Promise<{ locked: boolean; retryAfterSeconds?: number }> {
* Record a failed login attempt const key = email.toLowerCase();
*/ const now = Date.now();
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 }; // 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();
if (data) { const row = await this.db
record = JSON.parse(data); .prepare('SELECT attempts FROM login_attempts WHERE email = ?')
record.attempts += 1; .bind(key)
} else { .first<{ attempts: number }>();
record = { attempts: 1 };
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 };
} }
// 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,
};
}
// 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,
});
return { locked: false }; return { locked: false };
} }
/**
* Clear login attempts on successful login
*/
async clearLoginAttempts(email: string): Promise<void> { async clearLoginAttempts(email: string): Promise<void> {
const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`; await this.db.prepare('DELETE FROM login_attempts WHERE email = ?').bind(email.toLowerCase()).run();
await this.kv.delete(key);
} }
/** async checkApiRateLimit(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
* Check API rate limit for a user or IP const nowSec = Math.floor(Date.now() / 1000);
* Returns { allowed: boolean, remaining: number, retryAfterSeconds?: number } const windowStart = nowSec - (nowSec % CONFIG.API_WINDOW_SECONDS);
*/ const windowEnd = windowStart + CONFIG.API_WINDOW_SECONDS;
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}`;
const countStr = await this.kv.get(key); const row = await this.db
const count = countStr ? parseInt(countStr, 10) : 0; .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) { if (count >= CONFIG.API_REQUESTS_PER_MINUTE) {
const retryAfterSeconds = CONFIG.API_WINDOW_SECONDS - (now % CONFIG.API_WINDOW_SECONDS);
return { return {
allowed: false, allowed: false,
remaining: 0, remaining: 0,
retryAfterSeconds, retryAfterSeconds: windowEnd - nowSec,
}; };
} }
@@ -137,35 +111,27 @@ export class RateLimitService {
}; };
} }
/**
* Increment API request count
*/
async incrementApiCount(identifier: string): Promise<void> { async incrementApiCount(identifier: string): Promise<void> {
const now = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
const windowStart = now - (now % CONFIG.API_WINDOW_SECONDS); const windowStart = nowSec - (nowSec % CONFIG.API_WINDOW_SECONDS);
const key = `${KEYS.API_RATE}${identifier}:${windowStart}`;
const countStr = await this.kv.get(key); // Atomic increment via UPSERT.
const count = countStr ? parseInt(countStr, 10) : 0; await this.db
.prepare(
await this.kv.put(key, (count + 1).toString(), { 'INSERT INTO api_rate_limits(identifier, window_start, count) VALUES(?, ?, 1) ' +
expirationTtl: CONFIG.API_WINDOW_SECONDS + 10, // Slight buffer '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 { export function getClientIdentifier(request: Request): string {
// Cloudflare provides the real client IP
const cfIp = request.headers.get('CF-Connecting-IP'); const cfIp = request.headers.get('CF-Connecting-IP');
if (cfIp) return cfIp; if (cfIp) return cfIp;
// Fallback for local development
const forwardedFor = request.headers.get('X-Forwarded-For'); const forwardedFor = request.headers.get('X-Forwarded-For');
if (forwardedFor) return forwardedFor.split(',')[0].trim(); if (forwardedFor) return forwardedFor.split(',')[0].trim();
// Last resort
return 'unknown'; return 'unknown';
} }
+281 -177
View File
@@ -1,256 +1,360 @@
import { Env, User, Cipher, Folder, Attachment } from '../types'; import { User, Cipher, Folder, Attachment } from '../types';
const KEYS = { // D1-backed storage.
CONFIG_REGISTERED: 'config:registered', // Contract:
CONFIG_SETUP_DISABLED: 'config:setup_disabled', // - All methods are scoped by userId where applicable.
USER_PREFIX: 'user:', // - Uses SQL constraints (PK/unique/FK) to avoid KV-style index race conditions.
CIPHER_PREFIX: 'cipher:', // - Revision date is maintained per user for Bitwarden sync.
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:',
};
export class StorageService { export class StorageService {
constructor(private kv: KVNamespace) {} constructor(private db: D1Database) {}
// --- Config / setup ---
// Registration status
async isRegistered(): Promise<boolean> { async isRegistered(): Promise<boolean> {
const value = await this.kv.get(KEYS.CONFIG_REGISTERED); const row = await this.db.prepare('SELECT value FROM config WHERE key = ?').bind('registered').first<{ value: string }>();
return value === 'true'; return row?.value === 'true';
} }
async setRegistered(): Promise<void> { async setRegistered(): Promise<void> {
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<boolean> { async isSetupDisabled(): Promise<boolean> {
const value = await this.kv.get(KEYS.CONFIG_SETUP_DISABLED); const row = await this.db.prepare('SELECT value FROM config WHERE key = ?').bind('setup_disabled').first<{ value: string }>();
return value === 'true'; return row?.value === 'true';
} }
async setSetupDisabled(): Promise<void> { async setSetupDisabled(): Promise<void> {
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<User | null> { async getUser(email: string): Promise<User | null> {
const data = await this.kv.get(`${KEYS.USER_PREFIX}${email.toLowerCase()}`); const row = await this.db
return data ? JSON.parse(data) : null; .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<any>();
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<User | null> { async getUserById(id: string): Promise<User | null> {
// Get user email from id mapping const row = await this.db
const email = await this.kv.get(`userid:${id}`); .prepare(
if (!email) return null; '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 = ?'
return this.getUser(email); )
.bind(id)
.first<any>();
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<void> { async saveUser(user: User): Promise<void> {
await this.kv.put(`${KEYS.USER_PREFIX}${user.email.toLowerCase()}`, JSON.stringify(user)); const email = user.email.toLowerCase();
await this.kv.put(`userid:${user.id}`, 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<Cipher | null> { async getCipher(id: string): Promise<Cipher | null> {
const data = await this.kv.get(`${KEYS.CIPHER_PREFIX}${id}`); const row = await this.db.prepare('SELECT data FROM ciphers WHERE id = ?').bind(id).first<{ data: string }>();
return data ? JSON.parse(data) : null; return row?.data ? (JSON.parse(row.data) as Cipher) : null;
} }
async saveCipher(cipher: Cipher): Promise<void> { async saveCipher(cipher: Cipher): Promise<void> {
await this.kv.put(`${KEYS.CIPHER_PREFIX}${cipher.id}`, JSON.stringify(cipher)); const data = JSON.stringify(cipher);
await this.db
// Update index .prepare(
const index = await this.getCipherIds(cipher.userId); 'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' +
if (!index.includes(cipher.id)) { 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
index.push(cipher.id); 'ON CONFLICT(id) DO UPDATE SET ' +
await this.kv.put(`${KEYS.CIPHERS_INDEX}:${cipher.userId}`, JSON.stringify(index)); '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<void> { async deleteCipher(id: string, userId: string): Promise<void> {
await this.kv.delete(`${KEYS.CIPHER_PREFIX}${id}`); // hard delete
await this.db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run();
// 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<string[]> {
const data = await this.kv.get(`${KEYS.CIPHERS_INDEX}:${userId}`);
return data ? JSON.parse(data) : [];
} }
async getAllCiphers(userId: string): Promise<Cipher[]> { async getAllCiphers(userId: string): Promise<Cipher[]> {
const ids = await this.getCipherIds(userId); const res = await this.db.prepare('SELECT data FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC').bind(userId).all<{ data: string }>();
const ciphers: Cipher[] = []; return (res.results || []).map(r => JSON.parse(r.data) as Cipher);
for (const id of ids) {
const cipher = await this.getCipher(id);
if (cipher) ciphers.push(cipher);
} }
return ciphers;
}
// Folder operations
async getFolder(id: string): Promise<Folder | null> {
const data = await this.kv.get(`${KEYS.FOLDER_PREFIX}${id}`);
return data ? JSON.parse(data) : null;
}
async saveFolder(folder: Folder): Promise<void> {
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<void> {
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<string[]> {
const data = await this.kv.get(`${KEYS.FOLDERS_INDEX}:${userId}`);
return data ? JSON.parse(data) : [];
}
async getAllFolders(userId: string): Promise<Folder[]> {
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<void> {
// 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<string | null> {
return await this.kv.get(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`);
}
async deleteRefreshToken(token: string): Promise<void> {
await this.kv.delete(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`);
}
// Revision date operations (for incremental sync)
async getRevisionDate(userId: string): Promise<string> {
const date = await this.kv.get(`${KEYS.REVISION_DATE_PREFIX}${userId}`);
return date || new Date().toISOString();
}
async updateRevisionDate(userId: string): Promise<string> {
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<Cipher[]> { async getCiphersByIds(ids: string[], userId: string): Promise<Cipher[]> {
const ciphers: Cipher[] = []; if (ids.length === 0) return [];
for (const id of ids) { // D1 doesn't support binding arrays directly; build placeholders.
const cipher = await this.getCipher(id); const placeholders = ids.map(() => '?').join(',');
if (cipher && cipher.userId === userId) { const stmt = this.db.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`);
ciphers.push(cipher); const res = await stmt.bind(userId, ...ids).all<{ data: string }>();
} return (res.results || []).map(r => JSON.parse(r.data) as Cipher);
}
return ciphers;
} }
async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise<void> { async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise<void> {
if (ids.length === 0) return;
const now = new Date().toISOString(); 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) { for (const id of ids) {
const cipher = await this.getCipher(id); const row = await this.db
if (cipher && cipher.userId === userId) { .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.folderId = folderId;
cipher.updatedAt = now; cipher.updatedAt = now;
await this.saveCipher(cipher); await this.saveCipher(cipher);
} }
}
await this.updateRevisionDate(userId); await this.updateRevisionDate(userId);
} }
// Attachment operations // --- Folders ---
async getFolder(id: string): Promise<Folder | null> {
const row = await this.db
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE id = ?')
.bind(id)
.first<any>();
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<void> {
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<void> {
await this.db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run();
}
async getAllFolders(userId: string): Promise<Folder[]> {
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<any>();
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<Attachment | null> { async getAttachment(id: string): Promise<Attachment | null> {
const data = await this.kv.get(`${KEYS.ATTACHMENT_PREFIX}${id}`); const row = await this.db
return data ? JSON.parse(data) : null; .prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE id = ?')
.bind(id)
.first<any>();
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<void> { async saveAttachment(attachment: Attachment): Promise<void> {
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<void> { async deleteAttachment(id: string): Promise<void> {
await this.kv.delete(`${KEYS.ATTACHMENT_PREFIX}${id}`); await this.db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
}
async getAttachmentIdsByCipher(cipherId: string): Promise<string[]> {
const data = await this.kv.get(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`);
return data ? JSON.parse(data) : [];
} }
async getAttachmentsByCipher(cipherId: string): Promise<Attachment[]> { async getAttachmentsByCipher(cipherId: string): Promise<Attachment[]> {
const ids = await this.getAttachmentIdsByCipher(cipherId); const res = await this.db
const attachments: Attachment[] = []; .prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?')
for (const id of ids) { .bind(cipherId)
const attachment = await this.getAttachment(id); .all<any>();
if (attachment) attachments.push(attachment); return (res.results || []).map(r => ({
} id: r.id,
return attachments; 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<void> { async addAttachmentToCipher(cipherId: string, attachmentId: string): Promise<void> {
const ids = await this.getAttachmentIdsByCipher(cipherId); // Kept for API compatibility; no-op because attachments table already links cipher_id.
if (!ids.includes(attachmentId)) { // We still validate that the attachment exists and belongs to cipher.
ids.push(attachmentId); await this.db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run();
await this.kv.put(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`, JSON.stringify(ids));
}
} }
async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> { async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
const ids = await this.getAttachmentIdsByCipher(cipherId); // No-op: schema uses NOT NULL cipher_id.
const newIds = ids.filter(id => id !== attachmentId); // Callers always delete attachment row afterwards, so this method is kept for compatibility only.
await this.kv.put(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`, JSON.stringify(newIds)); void cipherId;
void attachmentId;
} }
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> { async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
const ids = await this.getAttachmentIdsByCipher(cipherId); await this.db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
for (const id of ids) {
await this.deleteAttachment(id);
}
await this.kv.delete(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`);
} }
async updateCipherRevisionDate(cipherId: string): Promise<void> { async updateCipherRevisionDate(cipherId: string): Promise<void> {
const cipher = await this.getCipher(cipherId); const cipher = await this.getCipher(cipherId);
if (cipher) { if (!cipher) return;
cipher.updatedAt = new Date().toISOString(); cipher.updatedAt = new Date().toISOString();
await this.saveCipher(cipher); await this.saveCipher(cipher);
await this.updateRevisionDate(cipher.userId); await this.updateRevisionDate(cipher.userId);
} }
// --- Refresh tokens ---
async saveRefreshToken(token: string, userId: string, expiresAtMs?: number): Promise<void> {
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<string | null> {
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<void> {
await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
}
// --- Revision dates ---
async getRevisionDate(userId: string): Promise<string> {
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<string> {
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;
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
// Environment bindings // Environment bindings
export interface Env { export interface Env {
VAULT: KVNamespace; DB: D1Database;
ATTACHMENTS: R2Bucket; ATTACHMENTS: R2Bucket;
JWT_SECRET: string; JWT_SECRET: string;
} }
+5 -3
View File
@@ -3,9 +3,11 @@ main = "src/index.ts"
compatibility_date = "2024-01-01" compatibility_date = "2024-01-01"
# KV Namespace for storing vault data # KV Namespace for storing vault data
[[kv_namespaces]] # D1 Database for storing vault data
binding = "VAULT" [[d1_databases]]
id = "placeholder" binding = "DB"
database_name = "nodewarden-db"
database_id = "placeholde
# R2 Bucket for storing attachments # R2 Bucket for storing attachments
[[r2_buckets]] [[r2_buckets]]