mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
fix: enhance attachment handling and folder deletion logic; improve error responses and rate limiting
This commit is contained in:
@@ -234,7 +234,6 @@ export async function handlePublicDownloadAttachment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
|
||||||
// Verify attachment exists
|
// Verify attachment exists
|
||||||
const attachment = await storage.getAttachment(attachmentId);
|
const attachment = await storage.getAttachment(attachmentId);
|
||||||
@@ -250,6 +249,11 @@ export async function handlePublicDownloadAttachment(
|
|||||||
return errorResponse('Attachment file not found', 404);
|
return errorResponse('Attachment file not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp);
|
||||||
|
if (!firstUse) {
|
||||||
|
return errorResponse('Invalid or expired token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(object.body, {
|
return new Response(object.body, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': 'application/octet-stream',
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
|
|||||||
return errorResponse('Folder not found', 404);
|
return errorResponse('Folder not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await storage.clearFolderFromCiphers(userId, id);
|
||||||
await storage.deleteFolder(id, userId);
|
await storage.deleteFolder(id, userId);
|
||||||
await storage.updateRevisionDate(userId);
|
await storage.updateRevisionDate(userId);
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
|
|
||||||
let body: Record<string, string>;
|
let body: Record<string, string>;
|
||||||
const contentType = request.headers.get('content-type') || '';
|
const contentType = request.headers.get('content-type') || '';
|
||||||
|
try {
|
||||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
body = Object.fromEntries(formData.entries()) as Record<string, string>;
|
body = Object.fromEntries(formData.entries()) as Record<string, string>;
|
||||||
} else {
|
} else {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return identityErrorResponse('Invalid request payload', 'invalid_request', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const grantType = body.grant_type;
|
const grantType = body.grant_type;
|
||||||
@@ -108,12 +111,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
// Refresh token
|
// Refresh token
|
||||||
const refreshToken = body.refresh_token;
|
const refreshToken = body.refresh_token;
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
return errorResponse('Refresh token is required', 400);
|
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await auth.refreshAccessToken(refreshToken);
|
const result = await auth.refreshAccessToken(refreshToken);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return errorResponse('Invalid refresh token', 401);
|
return identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revoke old refresh token (prevent reuse)
|
// Revoke old refresh token (prevent reuse)
|
||||||
@@ -158,7 +161,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
return jsonResponse(response);
|
return jsonResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorResponse('Unsupported grant type', 400);
|
return identityErrorResponse('Unsupported grant type', 'unsupported_grant_type', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /identity/accounts/prelogin
|
// POST /identity/accounts/prelogin
|
||||||
|
|||||||
@@ -649,8 +649,6 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string {
|
|||||||
creating: '正在创建…',
|
creating: '正在创建…',
|
||||||
doneTitle: '初始化完成',
|
doneTitle: '初始化完成',
|
||||||
doneDesc: '服务已就绪。在 Bitwarden 客户端中填入以下服务器地址:',
|
doneDesc: '服务已就绪。在 Bitwarden 客户端中填入以下服务器地址:',
|
||||||
important: '重要提示',
|
|
||||||
limitations: '本项目仅支持单用户:不能添加新用户;不支持修改主密码;如果忘记主密码,只能重新部署并重新注册。',
|
|
||||||
hideTitle: '隐藏初始化页',
|
hideTitle: '隐藏初始化页',
|
||||||
hideDesc: '隐藏后,初始化页对任何人都会返回 404。你的密码库仍可正常使用。',
|
hideDesc: '隐藏后,初始化页对任何人都会返回 404。你的密码库仍可正常使用。',
|
||||||
hideBtn: '隐藏初始化页',
|
hideBtn: '隐藏初始化页',
|
||||||
@@ -738,8 +736,6 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string {
|
|||||||
creating: 'Creating…',
|
creating: 'Creating…',
|
||||||
doneTitle: 'Setup complete',
|
doneTitle: 'Setup complete',
|
||||||
doneDesc: 'Your server is ready. Use this URL in Bitwarden clients:',
|
doneDesc: 'Your server is ready. Use this URL in Bitwarden clients:',
|
||||||
important: 'Important',
|
|
||||||
limitations: 'Single user only: no additional users, no master password change. If forgotten, redeploy and register again.',
|
|
||||||
hideTitle: 'Hide setup page',
|
hideTitle: 'Hide setup page',
|
||||||
hideDesc: 'After hiding, this page returns 404 for everyone. Vault still works.',
|
hideDesc: 'After hiding, this page returns 404 for everyone. Vault still works.',
|
||||||
hideBtn: 'Hide setup page',
|
hideBtn: 'Hide setup page',
|
||||||
@@ -843,8 +839,6 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string {
|
|||||||
setText('submitBtn', t('create'));
|
setText('submitBtn', t('create'));
|
||||||
setText('t_done_title', t('doneTitle'));
|
setText('t_done_title', t('doneTitle'));
|
||||||
setText('t_done_desc', t('doneDesc'));
|
setText('t_done_desc', t('doneDesc'));
|
||||||
setText('t_important', t('important'));
|
|
||||||
setText('t_limitations', t('limitations'));
|
|
||||||
setText('t_hide_title', t('hideTitle'));
|
setText('t_hide_title', t('hideTitle'));
|
||||||
setText('t_hide_desc', t('hideDesc'));
|
setText('t_hide_desc', t('hideDesc'));
|
||||||
setText('hideBtn', t('hideBtn'));
|
setText('hideBtn', t('hideBtn'));
|
||||||
|
|||||||
+12
-7
@@ -6,6 +6,9 @@ import { cipherToResponse } from './ciphers';
|
|||||||
// 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.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
||||||
|
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
||||||
|
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -61,11 +64,13 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
folders: folderResponses,
|
folders: folderResponses,
|
||||||
collections: [],
|
collections: [],
|
||||||
ciphers: cipherResponses,
|
ciphers: cipherResponses,
|
||||||
domains: {
|
domains: excludeDomains
|
||||||
equivalentDomains: [],
|
? null
|
||||||
globalEquivalentDomains: [],
|
: {
|
||||||
object: 'domains',
|
equivalentDomains: [],
|
||||||
},
|
globalEquivalentDomains: [],
|
||||||
|
object: 'domains',
|
||||||
|
},
|
||||||
policies: [],
|
policies: [],
|
||||||
sends: [],
|
sends: [],
|
||||||
// PascalCase for desktop/browser clients
|
// PascalCase for desktop/browser clients
|
||||||
@@ -81,7 +86,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
},
|
},
|
||||||
MasterKeyEncryptedUserKey: user.key,
|
MasterKeyEncryptedUserKey: user.key,
|
||||||
MasterKeyWrappedUserKey: user.key,
|
MasterKeyWrappedUserKey: user.key,
|
||||||
Salt: user.email,
|
Salt: user.email.toLowerCase(),
|
||||||
Object: 'masterPasswordUnlock',
|
Object: 'masterPasswordUnlock',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -96,7 +101,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
},
|
},
|
||||||
masterKeyWrappedUserKey: user.key,
|
masterKeyWrappedUserKey: user.key,
|
||||||
masterKeyEncryptedUserKey: user.key,
|
masterKeyEncryptedUserKey: user.key,
|
||||||
salt: user.email,
|
salt: user.email.toLowerCase(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
object: 'sync',
|
object: 'sync',
|
||||||
|
|||||||
+66
-5
@@ -49,6 +49,26 @@ import {
|
|||||||
handlePublicDownloadAttachment,
|
handlePublicDownloadAttachment,
|
||||||
} from './handlers/attachments';
|
} from './handlers/attachments';
|
||||||
|
|
||||||
|
function isSameOriginWriteRequest(request: Request): boolean {
|
||||||
|
const targetOrigin = new URL(request.url).origin;
|
||||||
|
const origin = request.headers.get('Origin');
|
||||||
|
if (origin) {
|
||||||
|
return origin === targetOrigin;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referer = request.headers.get('Referer');
|
||||||
|
if (referer) {
|
||||||
|
try {
|
||||||
|
return new URL(referer).origin === targetOrigin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require browser-origin evidence for setup/register write operations.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function getNwIconSvg(): string {
|
function getNwIconSvg(): string {
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`;
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`;
|
||||||
}
|
}
|
||||||
@@ -63,19 +83,54 @@ function handleNwFavicon(): Response {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidIconHostname(hostname: string): boolean {
|
||||||
|
if (!hostname) return false;
|
||||||
|
if (hostname.length > 253) return false;
|
||||||
|
|
||||||
|
const normalized = hostname.toLowerCase();
|
||||||
|
const domainPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
||||||
|
const ipv4Pattern = /^(?:\d{1,3}\.){3}\d{1,3}$/;
|
||||||
|
|
||||||
|
if (domainPattern.test(normalized)) return true;
|
||||||
|
if (!ipv4Pattern.test(normalized)) return false;
|
||||||
|
|
||||||
|
const parts = normalized.split('.');
|
||||||
|
return parts.every(p => {
|
||||||
|
const n = Number(p);
|
||||||
|
return Number.isInteger(n) && n >= 0 && n <= 255;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Icons handler - proxy to Bitwarden's official icon service
|
// Icons handler - proxy to Bitwarden's official icon service
|
||||||
async function handleGetIcon(request: Request, env: Env, hostname: string): Promise<Response> {
|
async function handleGetIcon(request: Request, env: Env, hostname: string): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
void env;
|
||||||
|
const normalizedHostname = hostname.toLowerCase();
|
||||||
|
if (!isValidIconHostname(normalizedHostname)) {
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = caches.default;
|
||||||
|
const cacheKey = new Request(`https://nodewarden-icons.local/icons/${normalizedHostname}/icon.png`, { method: 'GET' });
|
||||||
|
const cached = await cache.match(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
// Use Bitwarden's official icon service
|
// Use Bitwarden's official icon service
|
||||||
const iconUrl = `https://icons.bitwarden.net/${hostname}/icon.png`;
|
const iconUrl = `https://icons.bitwarden.net/${normalizedHostname}/icon.png`;
|
||||||
const resp = await fetch(iconUrl, {
|
const resp = await fetch(iconUrl, {
|
||||||
headers: { 'User-Agent': 'NodeWarden/1.0' },
|
headers: { 'User-Agent': 'NodeWarden/1.0' },
|
||||||
redirect: 'follow',
|
redirect: 'follow',
|
||||||
|
cf: {
|
||||||
|
cacheEverything: true,
|
||||||
|
cacheTtl: 604800,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const body = await resp.arrayBuffer();
|
const body = await resp.arrayBuffer();
|
||||||
return new Response(body, {
|
const iconResponse = new Response(body, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
||||||
@@ -83,6 +138,8 @@ async function handleGetIcon(request: Request, env: Env, hostname: string): Prom
|
|||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await cache.put(cacheKey, iconResponse.clone());
|
||||||
|
return iconResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
@@ -116,6 +173,9 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
|
|
||||||
// Disable setup page (one-way)
|
// Disable setup page (one-way)
|
||||||
if (path === '/setup/disable' && method === 'POST') {
|
if (path === '/setup/disable' && method === 'POST') {
|
||||||
|
if (!isSameOriginWriteRequest(request)) {
|
||||||
|
return errorResponse('Forbidden origin', 403);
|
||||||
|
}
|
||||||
return handleDisableSetup(request, env);
|
return handleDisableSetup(request, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +282,9 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
|
|
||||||
// Registration endpoint (no auth required, but only works once)
|
// Registration endpoint (no auth required, but only works once)
|
||||||
if (path === '/api/accounts/register' && method === 'POST') {
|
if (path === '/api/accounts/register' && method === 'POST') {
|
||||||
|
if (!isSameOriginWriteRequest(request)) {
|
||||||
|
return errorResponse('Forbidden origin', 403);
|
||||||
|
}
|
||||||
return handleRegister(request, env);
|
return handleRegister(request, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +310,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
if (isWriteMethod) {
|
if (isWriteMethod) {
|
||||||
const rateLimit = new RateLimitService(env.DB);
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
const clientId = getClientIdentifier(request);
|
const clientId = getClientIdentifier(request);
|
||||||
const rateLimitCheck = await rateLimit.checkApiRateLimit(userId + ':' + clientId + ':write');
|
const rateLimitCheck = await rateLimit.consumeApiWriteBudget(userId + ':' + clientId + ':write');
|
||||||
|
|
||||||
if (!rateLimitCheck.allowed) {
|
if (!rateLimitCheck.allowed) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
@@ -262,8 +325,6 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await rateLimit.incrementApiCount(userId + ':' + clientId + ':write');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block account operations that could change password or delete user
|
// Block account operations that could change password or delete user
|
||||||
|
|||||||
+16
-24
@@ -6,7 +6,7 @@
|
|||||||
// Rate limit configuration
|
// Rate limit configuration
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
// Friendly default: short cooldown instead of long lockouts.
|
// Friendly default: short cooldown instead of long lockouts.
|
||||||
LOGIN_MAX_ATTEMPTS: 8,
|
LOGIN_MAX_ATTEMPTS: 5,
|
||||||
LOGIN_LOCKOUT_MINUTES: 2,
|
LOGIN_LOCKOUT_MINUTES: 2,
|
||||||
|
|
||||||
// Write operations only (POST/PUT/DELETE/PATCH) should use this budget.
|
// Write operations only (POST/PUT/DELETE/PATCH) should use this budget.
|
||||||
@@ -113,18 +113,26 @@ export class RateLimitService {
|
|||||||
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
|
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkApiRateLimit(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
// Atomically consume one write budget unit for the current fixed window.
|
||||||
|
// Uses SQLite UPSERT-with-WHERE so requests at/over limit do not increment.
|
||||||
|
async consumeApiWriteBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const windowStart = nowSec - (nowSec % CONFIG.API_WINDOW_SECONDS);
|
const windowStart = nowSec - (nowSec % CONFIG.API_WINDOW_SECONDS);
|
||||||
const windowEnd = windowStart + CONFIG.API_WINDOW_SECONDS;
|
const windowEnd = windowStart + CONFIG.API_WINDOW_SECONDS;
|
||||||
|
|
||||||
const row = await this.db
|
const row = await this.db
|
||||||
.prepare('SELECT count FROM api_rate_limits WHERE identifier = ? AND window_start = ?')
|
.prepare(
|
||||||
.bind(identifier, windowStart)
|
'INSERT INTO api_rate_limits(identifier, window_start, count) VALUES(?, ?, 1) ' +
|
||||||
|
'ON CONFLICT(identifier, window_start) DO UPDATE SET count = count + 1 ' +
|
||||||
|
'WHERE api_rate_limits.count < ? ' +
|
||||||
|
'RETURNING count'
|
||||||
|
)
|
||||||
|
.bind(identifier, windowStart, CONFIG.API_WRITE_REQUESTS_PER_MINUTE)
|
||||||
.first<{ count: number }>();
|
.first<{ count: number }>();
|
||||||
|
|
||||||
const count = row?.count || 0;
|
// No returned row means conflict happened and WHERE prevented the increment:
|
||||||
if (count >= CONFIG.API_WRITE_REQUESTS_PER_MINUTE) {
|
// current count is already at/above the configured limit.
|
||||||
|
if (!row) {
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
remaining: 0,
|
remaining: 0,
|
||||||
@@ -132,24 +140,8 @@ export class RateLimitService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const remaining = Math.max(0, CONFIG.API_WRITE_REQUESTS_PER_MINUTE - row.count);
|
||||||
allowed: true,
|
return { allowed: true, remaining };
|
||||||
remaining: CONFIG.API_WRITE_REQUESTS_PER_MINUTE - count,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async incrementApiCount(identifier: string): Promise<void> {
|
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
|
||||||
const windowStart = nowSec - (nowSec % CONFIG.API_WINDOW_SECONDS);
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { User, Cipher, Folder, Attachment } from '../types';
|
|||||||
// - Revision date is maintained per user for Bitwarden sync.
|
// - Revision date is maintained per user for Bitwarden sync.
|
||||||
|
|
||||||
export class StorageService {
|
export class StorageService {
|
||||||
|
private static attachmentTokenTableReady = false;
|
||||||
|
|
||||||
constructor(private db: D1Database) {}
|
constructor(private db: D1Database) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -395,6 +397,23 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
|
|||||||
await this.db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
await this.db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear folder references from all ciphers owned by the user.
|
||||||
|
// Without this, deleting a folder leaves stale folderId values in cipher JSON.
|
||||||
|
async clearFolderFromCiphers(userId: string, folderId: string): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const res = await this.db
|
||||||
|
.prepare('SELECT data FROM ciphers WHERE user_id = ? AND folder_id = ?')
|
||||||
|
.bind(userId, folderId)
|
||||||
|
.all<{ data: string }>();
|
||||||
|
|
||||||
|
for (const row of (res.results || [])) {
|
||||||
|
const cipher = JSON.parse(row.data) as Cipher;
|
||||||
|
cipher.folderId = null;
|
||||||
|
cipher.updatedAt = now;
|
||||||
|
await this.saveCipher(cipher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getAllFolders(userId: string): Promise<Folder[]> {
|
async getAllFolders(userId: string): Promise<Folder[]> {
|
||||||
const res = await this.db
|
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')
|
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC')
|
||||||
@@ -579,4 +598,37 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
|
|||||||
.run();
|
.run();
|
||||||
return date;
|
return date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- One-time attachment download tokens ---
|
||||||
|
|
||||||
|
private async ensureUsedAttachmentDownloadTokenTable(): Promise<void> {
|
||||||
|
if (StorageService.attachmentTokenTableReady) return;
|
||||||
|
|
||||||
|
await this.db.prepare(
|
||||||
|
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
|
||||||
|
'jti TEXT PRIMARY KEY, ' +
|
||||||
|
'expires_at INTEGER NOT NULL' +
|
||||||
|
')'
|
||||||
|
).run();
|
||||||
|
|
||||||
|
StorageService.attachmentTokenTableReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marks an attachment download token JTI as consumed.
|
||||||
|
// Returns true only on first use. Reuse returns false.
|
||||||
|
async consumeAttachmentDownloadToken(jti: string, expUnixSeconds: number): Promise<boolean> {
|
||||||
|
await this.ensureUsedAttachmentDownloadTokenTable();
|
||||||
|
|
||||||
|
const nowMs = Date.now();
|
||||||
|
// Best-effort cleanup of expired entries.
|
||||||
|
await this.db.prepare('DELETE FROM used_attachment_download_tokens WHERE expires_at < ?').bind(nowMs).run();
|
||||||
|
|
||||||
|
const expiresAtMs = expUnixSeconds * 1000;
|
||||||
|
const result = await this.db.prepare(
|
||||||
|
'INSERT INTO used_attachment_download_tokens(jti, expires_at) VALUES(?, ?) ' +
|
||||||
|
'ON CONFLICT(jti) DO NOTHING'
|
||||||
|
).bind(jti, expiresAtMs).run();
|
||||||
|
|
||||||
|
return (result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export function createRefreshToken(): string {
|
|||||||
export interface FileDownloadClaims {
|
export interface FileDownloadClaims {
|
||||||
cipherId: string;
|
cipherId: string;
|
||||||
attachmentId: string;
|
attachmentId: string;
|
||||||
|
jti: string;
|
||||||
exp: number;
|
exp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +115,7 @@ export async function createFileDownloadToken(
|
|||||||
const payload: FileDownloadClaims = {
|
const payload: FileDownloadClaims = {
|
||||||
cipherId,
|
cipherId,
|
||||||
attachmentId,
|
attachmentId,
|
||||||
|
jti: createRefreshToken(),
|
||||||
exp: now + 300, // 5 minutes
|
exp: now + 300, // 5 minutes
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user