mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
refactor: enhance manual chunking in Vite config for better code splitting
This commit is contained in:
@@ -0,0 +1,644 @@
|
||||
import { Env, Send, SendAuthType, SendType } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import {
|
||||
getBlobStorageMaxBytes,
|
||||
getSendFileObjectKey,
|
||||
putBlobObject,
|
||||
deleteBlobObject,
|
||||
} from '../services/blob-store';
|
||||
import {
|
||||
formatSize,
|
||||
getAliasedProp,
|
||||
normalizeEmails,
|
||||
notifyVaultSyncForRequest,
|
||||
parseDate,
|
||||
parseFileLength,
|
||||
parseInteger,
|
||||
parseMaxAccessCount,
|
||||
parseSendAuthType,
|
||||
parseSendType,
|
||||
parseStoredSendData,
|
||||
sanitizeSendData,
|
||||
sendToResponse,
|
||||
setSendPassword,
|
||||
validateDeletionDate,
|
||||
} from './sends-shared';
|
||||
|
||||
export async function handleGetSends(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const url = new URL(request.url);
|
||||
const pagination = parsePagination(url);
|
||||
|
||||
let sends: Send[];
|
||||
let continuationToken: string | null = null;
|
||||
if (pagination) {
|
||||
const pageRows = await storage.getSendsPage(userId, pagination.limit + 1, pagination.offset);
|
||||
const hasNext = pageRows.length > pagination.limit;
|
||||
sends = hasNext ? pageRows.slice(0, pagination.limit) : pageRows;
|
||||
continuationToken = hasNext ? encodeContinuationToken(pagination.offset + sends.length) : null;
|
||||
} else {
|
||||
sends = await storage.getAllSends(userId);
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
data: sends.map(sendToResponse),
|
||||
object: 'list',
|
||||
continuationToken,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleGetSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
|
||||
export async function handleCreateSend(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const typeRaw = getAliasedProp(body, ['type', 'Type']);
|
||||
const sendType = parseSendType(typeRaw.value);
|
||||
if (sendType === null) {
|
||||
return errorResponse('Invalid Send type', 400);
|
||||
}
|
||||
if (sendType === SendType.File) {
|
||||
return errorResponse('File sends should use /api/sends/file/v2', 400);
|
||||
}
|
||||
|
||||
const nameRaw = getAliasedProp(body, ['name', 'Name']);
|
||||
const keyRaw = getAliasedProp(body, ['key', 'Key']);
|
||||
const deletionDateRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']);
|
||||
const textRaw = getAliasedProp(body, ['text', 'Text']);
|
||||
|
||||
if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) {
|
||||
return errorResponse('Name is required', 400);
|
||||
}
|
||||
if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) {
|
||||
return errorResponse('Key is required', 400);
|
||||
}
|
||||
|
||||
const deletionDate = parseDate(deletionDateRaw.value);
|
||||
if (!deletionDate) {
|
||||
return errorResponse('Invalid deletionDate', 400);
|
||||
}
|
||||
|
||||
const deletionValidation = validateDeletionDate(deletionDate);
|
||||
if (deletionValidation) return deletionValidation;
|
||||
|
||||
const sendData = sanitizeSendData(textRaw.value);
|
||||
if (!sendData) {
|
||||
return errorResponse('Send data not provided', 400);
|
||||
}
|
||||
|
||||
const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']);
|
||||
const maxAccess = parseMaxAccessCount(maxAccessRaw.value);
|
||||
if (!maxAccess.ok) return maxAccess.response;
|
||||
|
||||
const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']);
|
||||
const expirationDate = expirationRaw.value === null || expirationRaw.value === undefined
|
||||
? null
|
||||
: parseDate(expirationRaw.value);
|
||||
if (expirationRaw.value !== null && expirationRaw.value !== undefined && !expirationDate) {
|
||||
return errorResponse('Invalid expirationDate', 400);
|
||||
}
|
||||
|
||||
const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']);
|
||||
const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']);
|
||||
const notesRaw = getAliasedProp(body, ['notes', 'Notes']);
|
||||
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
|
||||
const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']);
|
||||
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
|
||||
|
||||
const requestedAuthType = parseSendAuthType(authTypeRaw.value);
|
||||
if (authTypeRaw.present && requestedAuthType === null) {
|
||||
return errorResponse('Invalid authType', 400);
|
||||
}
|
||||
|
||||
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
||||
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
|
||||
return errorResponse('Invalid emails', 400);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const send: Send = {
|
||||
id: generateUUID(),
|
||||
userId,
|
||||
type: sendType,
|
||||
name: nameRaw.value.trim(),
|
||||
notes: typeof notesRaw.value === 'string' ? notesRaw.value : null,
|
||||
data: JSON.stringify(sendData),
|
||||
key: keyRaw.value,
|
||||
passwordHash: null,
|
||||
passwordSalt: null,
|
||||
passwordIterations: null,
|
||||
authType: requestedAuthType ?? SendAuthType.None,
|
||||
emails: normalizedEmails,
|
||||
maxAccessCount: maxAccess.value,
|
||||
accessCount: 0,
|
||||
disabled: typeof disabledRaw.value === 'boolean' ? disabledRaw.value : false,
|
||||
hideEmail: typeof hideEmailRaw.value === 'boolean' ? hideEmailRaw.value : null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
expirationDate: expirationDate ? expirationDate.toISOString() : null,
|
||||
deletionDate: deletionDate.toISOString(),
|
||||
};
|
||||
|
||||
if (typeof passwordRaw.value === 'string' && passwordRaw.value.length > 0) {
|
||||
await setSendPassword(send, passwordRaw.value);
|
||||
} else if (send.authType === SendAuthType.Password) {
|
||||
return errorResponse('Password is required for password auth', 400);
|
||||
}
|
||||
|
||||
if (send.authType !== SendAuthType.Email) {
|
||||
send.emails = null;
|
||||
}
|
||||
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
|
||||
export async function handleCreateFileSendV2(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes);
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const typeRaw = getAliasedProp(body, ['type', 'Type']);
|
||||
const sendType = parseSendType(typeRaw.value);
|
||||
if (sendType !== SendType.File) {
|
||||
return errorResponse('Send content is not a file', 400);
|
||||
}
|
||||
|
||||
const fileLengthRaw = getAliasedProp(body, ['fileLength', 'FileLength']);
|
||||
const fileLengthParsed = parseFileLength(fileLengthRaw.value);
|
||||
if (!fileLengthParsed.ok) return fileLengthParsed.response;
|
||||
if (fileLengthParsed.value > maxFileSize) {
|
||||
return errorResponse('Send storage limit exceeded with this file', 400);
|
||||
}
|
||||
|
||||
const nameRaw = getAliasedProp(body, ['name', 'Name']);
|
||||
const keyRaw = getAliasedProp(body, ['key', 'Key']);
|
||||
const deletionDateRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']);
|
||||
const fileRaw = getAliasedProp(body, ['file', 'File']);
|
||||
|
||||
if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) {
|
||||
return errorResponse('Name is required', 400);
|
||||
}
|
||||
if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) {
|
||||
return errorResponse('Key is required', 400);
|
||||
}
|
||||
|
||||
const deletionDate = parseDate(deletionDateRaw.value);
|
||||
if (!deletionDate) {
|
||||
return errorResponse('Invalid deletionDate', 400);
|
||||
}
|
||||
const deletionValidation = validateDeletionDate(deletionDate);
|
||||
if (deletionValidation) return deletionValidation;
|
||||
|
||||
const fileData = sanitizeSendData(fileRaw.value);
|
||||
if (!fileData) {
|
||||
return errorResponse('Send data not provided', 400);
|
||||
}
|
||||
|
||||
const fileId = generateUUID();
|
||||
fileData.id = fileId;
|
||||
fileData.size = fileLengthParsed.value;
|
||||
fileData.sizeName = formatSize(fileLengthParsed.value);
|
||||
|
||||
const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']);
|
||||
const maxAccess = parseMaxAccessCount(maxAccessRaw.value);
|
||||
if (!maxAccess.ok) return maxAccess.response;
|
||||
|
||||
const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']);
|
||||
const expirationDate = expirationRaw.value === null || expirationRaw.value === undefined
|
||||
? null
|
||||
: parseDate(expirationRaw.value);
|
||||
if (expirationRaw.value !== null && expirationRaw.value !== undefined && !expirationDate) {
|
||||
return errorResponse('Invalid expirationDate', 400);
|
||||
}
|
||||
|
||||
const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']);
|
||||
const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']);
|
||||
const notesRaw = getAliasedProp(body, ['notes', 'Notes']);
|
||||
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
|
||||
const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']);
|
||||
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
|
||||
|
||||
const requestedAuthType = parseSendAuthType(authTypeRaw.value);
|
||||
if (authTypeRaw.present && requestedAuthType === null) {
|
||||
return errorResponse('Invalid authType', 400);
|
||||
}
|
||||
|
||||
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
||||
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
|
||||
return errorResponse('Invalid emails', 400);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const send: Send = {
|
||||
id: generateUUID(),
|
||||
userId,
|
||||
type: sendType,
|
||||
name: nameRaw.value.trim(),
|
||||
notes: typeof notesRaw.value === 'string' ? notesRaw.value : null,
|
||||
data: JSON.stringify(fileData),
|
||||
key: keyRaw.value,
|
||||
passwordHash: null,
|
||||
passwordSalt: null,
|
||||
passwordIterations: null,
|
||||
authType: requestedAuthType ?? SendAuthType.None,
|
||||
emails: normalizedEmails,
|
||||
maxAccessCount: maxAccess.value,
|
||||
accessCount: 0,
|
||||
disabled: typeof disabledRaw.value === 'boolean' ? disabledRaw.value : false,
|
||||
hideEmail: typeof hideEmailRaw.value === 'boolean' ? hideEmailRaw.value : null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
expirationDate: expirationDate ? expirationDate.toISOString() : null,
|
||||
deletionDate: deletionDate.toISOString(),
|
||||
};
|
||||
|
||||
if (typeof passwordRaw.value === 'string' && passwordRaw.value.length > 0) {
|
||||
await setSendPassword(send, passwordRaw.value);
|
||||
} else if (send.authType === SendAuthType.Password) {
|
||||
return errorResponse('Password is required for password auth', 400);
|
||||
}
|
||||
|
||||
if (send.authType !== SendAuthType.Email) {
|
||||
send.emails = null;
|
||||
}
|
||||
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse({
|
||||
fileUploadType: 0,
|
||||
object: 'send-fileUpload',
|
||||
url: `/api/sends/${send.id}/file/${fileId}`,
|
||||
sendResponse: sendToResponse(send),
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleGetSendFileUpload(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
sendId: string,
|
||||
fileId: string
|
||||
): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
if (send.type !== SendType.File) {
|
||||
return errorResponse('Send is not a file type send.', 400);
|
||||
}
|
||||
|
||||
const sendData = parseStoredSendData(send);
|
||||
const expectedFileId = typeof sendData.id === 'string' ? sendData.id : null;
|
||||
if (!expectedFileId || expectedFileId !== fileId) {
|
||||
return errorResponse('Send file does not match send data.', 400);
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
fileUploadType: 0,
|
||||
object: 'send-fileUpload',
|
||||
url: `/api/sends/${send.id}/file/${fileId}`,
|
||||
sendResponse: sendToResponse(send),
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleUploadSendFile(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
sendId: string,
|
||||
fileId: string
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes);
|
||||
const send = await storage.getSend(sendId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found. Unable to save the file.', 404);
|
||||
}
|
||||
if (send.type !== SendType.File) {
|
||||
return errorResponse('Send is not a file type send.', 400);
|
||||
}
|
||||
|
||||
const sendData = parseStoredSendData(send);
|
||||
const expectedFileId = typeof sendData.id === 'string' ? sendData.id : null;
|
||||
if (!expectedFileId || expectedFileId !== fileId) {
|
||||
return errorResponse('Send file does not match send data.', 400);
|
||||
}
|
||||
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
if (!contentType.includes('multipart/form-data')) {
|
||||
return errorResponse('Content-Type must be multipart/form-data', 400);
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('data') as File | null;
|
||||
if (!file) {
|
||||
return errorResponse('No file uploaded', 400);
|
||||
}
|
||||
|
||||
if (file.size > maxFileSize) {
|
||||
return errorResponse('Send storage limit exceeded with this file', 413);
|
||||
}
|
||||
|
||||
const expectedFileName = typeof sendData.fileName === 'string' ? sendData.fileName : null;
|
||||
if (expectedFileName && file.name !== expectedFileName) {
|
||||
return errorResponse('Send file name does not match.', 400);
|
||||
}
|
||||
|
||||
const expectedSize = parseInteger(sendData.size);
|
||||
if (expectedSize !== null && file.size !== expectedSize) {
|
||||
return errorResponse('Send file size does not match.', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
await putBlobObject(env, getSendFileObjectKey(sendId, fileId), file.stream(), {
|
||||
size: file.size,
|
||||
contentType: 'application/octet-stream',
|
||||
customMetadata: {
|
||||
sendId,
|
||||
fileId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes('KV object too large')) {
|
||||
return errorResponse('Send storage limit exceeded with this file', 413);
|
||||
}
|
||||
return errorResponse('Attachment storage is not configured', 500);
|
||||
}
|
||||
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const typeRaw = getAliasedProp(body, ['type', 'Type']);
|
||||
if (typeRaw.present) {
|
||||
const incomingType = parseSendType(typeRaw.value);
|
||||
if (incomingType === null) {
|
||||
return errorResponse('Invalid Send type', 400);
|
||||
}
|
||||
if (incomingType !== send.type) {
|
||||
return errorResponse("Sends can't change type", 400);
|
||||
}
|
||||
}
|
||||
|
||||
const deletionRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']);
|
||||
if (deletionRaw.present) {
|
||||
const deletionDate = parseDate(deletionRaw.value);
|
||||
if (!deletionDate) return errorResponse('Invalid deletionDate', 400);
|
||||
const deletionValidation = validateDeletionDate(deletionDate);
|
||||
if (deletionValidation) return deletionValidation;
|
||||
send.deletionDate = deletionDate.toISOString();
|
||||
}
|
||||
|
||||
const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']);
|
||||
if (expirationRaw.present) {
|
||||
if (expirationRaw.value === null || expirationRaw.value === '') {
|
||||
send.expirationDate = null;
|
||||
} else {
|
||||
const expiration = parseDate(expirationRaw.value);
|
||||
if (!expiration) return errorResponse('Invalid expirationDate', 400);
|
||||
send.expirationDate = expiration.toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
const nameRaw = getAliasedProp(body, ['name', 'Name']);
|
||||
if (nameRaw.present) {
|
||||
if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) {
|
||||
return errorResponse('Name is required', 400);
|
||||
}
|
||||
send.name = nameRaw.value.trim();
|
||||
}
|
||||
|
||||
const keyRaw = getAliasedProp(body, ['key', 'Key']);
|
||||
if (keyRaw.present) {
|
||||
if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) {
|
||||
return errorResponse('Key is required', 400);
|
||||
}
|
||||
send.key = keyRaw.value;
|
||||
}
|
||||
|
||||
const notesRaw = getAliasedProp(body, ['notes', 'Notes']);
|
||||
if (notesRaw.present) {
|
||||
send.notes = typeof notesRaw.value === 'string' ? notesRaw.value : null;
|
||||
}
|
||||
|
||||
const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']);
|
||||
if (disabledRaw.present) {
|
||||
if (typeof disabledRaw.value !== 'boolean') {
|
||||
return errorResponse('Invalid disabled', 400);
|
||||
}
|
||||
send.disabled = disabledRaw.value;
|
||||
}
|
||||
|
||||
const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']);
|
||||
if (hideEmailRaw.present) {
|
||||
if (hideEmailRaw.value === null) {
|
||||
send.hideEmail = null;
|
||||
} else if (typeof hideEmailRaw.value === 'boolean') {
|
||||
send.hideEmail = hideEmailRaw.value;
|
||||
} else {
|
||||
return errorResponse('Invalid hideEmail', 400);
|
||||
}
|
||||
}
|
||||
|
||||
const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']);
|
||||
if (maxAccessRaw.present) {
|
||||
const parsedMax = parseMaxAccessCount(maxAccessRaw.value);
|
||||
if (!parsedMax.ok) return parsedMax.response;
|
||||
send.maxAccessCount = parsedMax.value;
|
||||
}
|
||||
|
||||
if (send.type === SendType.Text) {
|
||||
const textRaw = getAliasedProp(body, ['text', 'Text']);
|
||||
if (textRaw.present) {
|
||||
const textData = sanitizeSendData(textRaw.value);
|
||||
if (!textData) {
|
||||
return errorResponse('Send data not provided', 400);
|
||||
}
|
||||
send.data = JSON.stringify(textData);
|
||||
}
|
||||
}
|
||||
|
||||
const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']);
|
||||
if (authTypeRaw.present) {
|
||||
const parsedAuthType = parseSendAuthType(authTypeRaw.value);
|
||||
if (parsedAuthType === null) {
|
||||
return errorResponse('Invalid authType', 400);
|
||||
}
|
||||
send.authType = parsedAuthType;
|
||||
if (parsedAuthType !== SendAuthType.Email) {
|
||||
send.emails = null;
|
||||
}
|
||||
}
|
||||
|
||||
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
|
||||
if (emailsRaw.present) {
|
||||
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
||||
if (emailsRaw.value !== null && normalizedEmails === null) {
|
||||
return errorResponse('Invalid emails', 400);
|
||||
}
|
||||
send.emails = normalizedEmails;
|
||||
if (send.emails) {
|
||||
send.authType = SendAuthType.Email;
|
||||
} else if (send.authType === SendAuthType.Email) {
|
||||
send.authType = SendAuthType.None;
|
||||
}
|
||||
}
|
||||
|
||||
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
|
||||
if (passwordRaw.present && typeof passwordRaw.value === 'string') {
|
||||
await setSendPassword(send, passwordRaw.value);
|
||||
}
|
||||
|
||||
if (send.authType === SendAuthType.Password && !send.passwordHash) {
|
||||
return errorResponse('Password is required for password auth', 400);
|
||||
}
|
||||
|
||||
send.updatedAt = new Date().toISOString();
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
|
||||
export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
|
||||
if (send.type === SendType.File) {
|
||||
const data = parseStoredSendData(send);
|
||||
const fileId = typeof data.id === 'string' ? data.id : null;
|
||||
if (fileId) {
|
||||
await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId));
|
||||
}
|
||||
}
|
||||
|
||||
await storage.deleteSend(sendId, userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
export async function handleBulkDeleteSends(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
let body: { ids?: string[] };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
if (!body.ids || !Array.isArray(body.ids)) {
|
||||
return errorResponse('ids array is required', 400);
|
||||
}
|
||||
|
||||
const sends = await storage.getSendsByIds(body.ids, userId);
|
||||
for (const send of sends) {
|
||||
if (send.type !== SendType.File) continue;
|
||||
const data = parseStoredSendData(send);
|
||||
const fileId = typeof data.id === 'string' ? data.id : null;
|
||||
if (fileId) {
|
||||
await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId));
|
||||
}
|
||||
}
|
||||
|
||||
const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
|
||||
if (revisionDate) {
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
|
||||
await setSendPassword(send, null);
|
||||
send.updatedAt = new Date().toISOString();
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
|
||||
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
|
||||
send.authType = SendAuthType.None;
|
||||
send.emails = null;
|
||||
send.updatedAt = new Date().toISOString();
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import { Env, SendType } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import {
|
||||
createSendAccessToken,
|
||||
createSendFileDownloadToken,
|
||||
verifySendAccessToken,
|
||||
verifySendFileDownloadToken,
|
||||
} from '../utils/jwt';
|
||||
import {
|
||||
getBlobObject,
|
||||
getSendFileObjectKey,
|
||||
} from '../services/blob-store';
|
||||
import {
|
||||
SEND_INACCESSIBLE_MSG,
|
||||
extractBearerToken,
|
||||
fromAccessId,
|
||||
getCreatorIdentifier,
|
||||
getSafeJwtSecret,
|
||||
hasEmailAuth,
|
||||
isSendAvailable,
|
||||
notifyVaultSyncForRequest,
|
||||
parseStoredSendData,
|
||||
resolveSendFromIdOrAccessId,
|
||||
sendPasswordLimitKey,
|
||||
sendPasswordLockedErrorResponse,
|
||||
sendPasswordLockedOAuthResponse,
|
||||
sendToAccessResponse,
|
||||
validatePublicSendAccess,
|
||||
verifySendPassword,
|
||||
verifySendPasswordHashB64,
|
||||
} from './sends-shared';
|
||||
|
||||
export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const sendId = fromAccessId(accessId);
|
||||
if (!sendId) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
|
||||
const send = await storage.getSend(sendId);
|
||||
if (!send || !isSendAvailable(send)) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
|
||||
let body: unknown = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
|
||||
let sendPasswordLimitIpKey: string | null = null;
|
||||
let sendPasswordRateLimit: RateLimitService | null = null;
|
||||
if (send.passwordHash) {
|
||||
const clientIdentifier = getClientIdentifier(request);
|
||||
if (!clientIdentifier) {
|
||||
return errorResponse('Client IP is required', 403);
|
||||
}
|
||||
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
|
||||
sendPasswordRateLimit = new RateLimitService(env.DB);
|
||||
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||
if (!sendPasswordCheck.allowed) {
|
||||
return sendPasswordLockedErrorResponse(sendPasswordCheck.retryAfterSeconds || 60);
|
||||
}
|
||||
}
|
||||
|
||||
const validation = await validatePublicSendAccess(send, body);
|
||||
if (!validation.ok) {
|
||||
if (validation.reason === 'invalid_password' && sendPasswordRateLimit && sendPasswordLimitIpKey) {
|
||||
const failed = await sendPasswordRateLimit.recordFailedLogin(sendPasswordLimitIpKey);
|
||||
if (failed.locked) {
|
||||
return sendPasswordLockedErrorResponse(failed.retryAfterSeconds || 60);
|
||||
}
|
||||
}
|
||||
return validation.response;
|
||||
}
|
||||
|
||||
if (send.passwordHash && sendPasswordRateLimit && sendPasswordLimitIpKey) {
|
||||
await sendPasswordRateLimit.clearLoginAttempts(sendPasswordLimitIpKey);
|
||||
}
|
||||
|
||||
if (send.type === SendType.Text) {
|
||||
const updated = await storage.incrementSendAccessCount(send.id);
|
||||
if (!updated) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
send.accessCount += 1;
|
||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
}
|
||||
|
||||
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
||||
return jsonResponse(sendToAccessResponse(send, creatorIdentifier));
|
||||
}
|
||||
|
||||
export async function handleAccessSendFile(
|
||||
request: Request,
|
||||
env: Env,
|
||||
idOrAccessId: string,
|
||||
fileId: string
|
||||
): Promise<Response> {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
|
||||
return errorResponse('Server configuration error', 500);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId);
|
||||
if (!send || !isSendAvailable(send) || send.type !== SendType.File) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
|
||||
const data = parseStoredSendData(send);
|
||||
const expectedFileId = typeof data.id === 'string' ? data.id : null;
|
||||
if (!expectedFileId || expectedFileId !== fileId) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
|
||||
let body: unknown = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
|
||||
let sendPasswordLimitIpKey: string | null = null;
|
||||
let sendPasswordRateLimit: RateLimitService | null = null;
|
||||
if (send.passwordHash) {
|
||||
const clientIdentifier = getClientIdentifier(request);
|
||||
if (!clientIdentifier) {
|
||||
return errorResponse('Client IP is required', 403);
|
||||
}
|
||||
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
|
||||
sendPasswordRateLimit = new RateLimitService(env.DB);
|
||||
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||
if (!sendPasswordCheck.allowed) {
|
||||
return sendPasswordLockedErrorResponse(sendPasswordCheck.retryAfterSeconds || 60);
|
||||
}
|
||||
}
|
||||
|
||||
const validation = await validatePublicSendAccess(send, body);
|
||||
if (!validation.ok) {
|
||||
if (validation.reason === 'invalid_password' && sendPasswordRateLimit && sendPasswordLimitIpKey) {
|
||||
const failed = await sendPasswordRateLimit.recordFailedLogin(sendPasswordLimitIpKey);
|
||||
if (failed.locked) {
|
||||
return sendPasswordLockedErrorResponse(failed.retryAfterSeconds || 60);
|
||||
}
|
||||
}
|
||||
return validation.response;
|
||||
}
|
||||
|
||||
if (send.passwordHash && sendPasswordRateLimit && sendPasswordLimitIpKey) {
|
||||
await sendPasswordRateLimit.clearLoginAttempts(sendPasswordLimitIpKey);
|
||||
}
|
||||
|
||||
const updated = await storage.incrementSendAccessCount(send.id);
|
||||
if (!updated) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
send.accessCount += 1;
|
||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
|
||||
const token = await createSendFileDownloadToken(send.id, fileId, secret);
|
||||
const url = new URL(request.url);
|
||||
const downloadUrl = `${url.origin}/api/sends/${send.id}/${fileId}?t=${token}`;
|
||||
|
||||
return jsonResponse({
|
||||
object: 'send-fileDownload',
|
||||
id: fileId,
|
||||
url: downloadUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleAccessSendV2(request: Request, env: Env): Promise<Response> {
|
||||
const jwt = getSafeJwtSecret(env);
|
||||
if (!jwt.ok) return jwt.response;
|
||||
|
||||
const token = extractBearerToken(request);
|
||||
if (!token) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
const claims = await verifySendAccessToken(token, jwt.secret);
|
||||
if (!claims) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(claims.sub);
|
||||
if (!send || !isSendAvailable(send)) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
|
||||
if (send.type === SendType.Text) {
|
||||
const updated = await storage.incrementSendAccessCount(send.id);
|
||||
if (!updated) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
send.accessCount += 1;
|
||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
}
|
||||
|
||||
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
||||
return jsonResponse(sendToAccessResponse(send, creatorIdentifier));
|
||||
}
|
||||
|
||||
export async function handleAccessSendFileV2(request: Request, env: Env, fileId: string): Promise<Response> {
|
||||
const jwt = getSafeJwtSecret(env);
|
||||
if (!jwt.ok) return jwt.response;
|
||||
|
||||
const token = extractBearerToken(request);
|
||||
if (!token) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
const claims = await verifySendAccessToken(token, jwt.secret);
|
||||
if (!claims) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(claims.sub);
|
||||
if (!send || !isSendAvailable(send) || send.type !== SendType.File) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
|
||||
const data = parseStoredSendData(send);
|
||||
const expectedFileId = typeof data.id === 'string' ? data.id : null;
|
||||
if (!expectedFileId || expectedFileId !== fileId) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
|
||||
const updated = await storage.incrementSendAccessCount(send.id);
|
||||
if (!updated) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
send.accessCount += 1;
|
||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
|
||||
const downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret);
|
||||
const url = new URL(request.url);
|
||||
const downloadUrl = `${url.origin}/api/sends/${send.id}/${fileId}?t=${downloadToken}`;
|
||||
|
||||
return jsonResponse({
|
||||
object: 'send-fileDownload',
|
||||
id: fileId,
|
||||
url: downloadUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleDownloadSendFile(
|
||||
request: Request,
|
||||
env: Env,
|
||||
sendId: string,
|
||||
fileId: string
|
||||
): Promise<Response> {
|
||||
const jwt = getSafeJwtSecret(env);
|
||||
if (!jwt.ok) return jwt.response;
|
||||
|
||||
const url = new URL(request.url);
|
||||
const token = url.searchParams.get('t') || url.searchParams.get('token');
|
||||
if (!token) {
|
||||
return errorResponse('Token required', 401);
|
||||
}
|
||||
|
||||
const claims = await verifySendFileDownloadToken(token, jwt.secret);
|
||||
if (!claims) {
|
||||
return errorResponse('Invalid or expired token', 401);
|
||||
}
|
||||
if (claims.sendId !== sendId || claims.fileId !== fileId) {
|
||||
return errorResponse('Token mismatch', 401);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
|
||||
if (!object) {
|
||||
return errorResponse('Send file not found', 404);
|
||||
}
|
||||
|
||||
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
|
||||
if (!firstUse) {
|
||||
return errorResponse('Invalid or expired token', 401);
|
||||
}
|
||||
|
||||
return new Response(object.body, {
|
||||
headers: {
|
||||
'Content-Type': object.contentType || 'application/octet-stream',
|
||||
'Content-Length': String(object.size),
|
||||
'Cache-Control': 'private, no-cache',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function issueSendAccessToken(
|
||||
env: Env,
|
||||
sendIdOrAccessId: string,
|
||||
passwordHashB64?: string | null,
|
||||
password?: string | null,
|
||||
rateLimit?: RateLimitService,
|
||||
sendPasswordLimitIpKey?: string
|
||||
): Promise<{ token: string } | { error: Response }> {
|
||||
const jwt = getSafeJwtSecret(env);
|
||||
if (!jwt.ok) {
|
||||
return { error: jwt.response };
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await resolveSendFromIdOrAccessId(storage, sendIdOrAccessId);
|
||||
|
||||
if (!send || !isSendAvailable(send)) {
|
||||
return {
|
||||
error: jsonResponse(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: SEND_INACCESSIBLE_MSG,
|
||||
send_access_error_type: 'send_not_available',
|
||||
ErrorModel: {
|
||||
Message: SEND_INACCESSIBLE_MSG,
|
||||
Object: 'error',
|
||||
},
|
||||
},
|
||||
400
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (hasEmailAuth(send)) {
|
||||
const message = 'Email verification for this Send is not supported by this server.';
|
||||
return {
|
||||
error: jsonResponse(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: message,
|
||||
send_access_error_type: 'email_verification_not_supported',
|
||||
ErrorModel: {
|
||||
Message: message,
|
||||
Object: 'error',
|
||||
},
|
||||
},
|
||||
400
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (send.passwordHash) {
|
||||
if (rateLimit && sendPasswordLimitIpKey) {
|
||||
const sendPasswordCheck = await rateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||
if (!sendPasswordCheck.allowed) {
|
||||
return {
|
||||
error: sendPasswordLockedOAuthResponse(sendPasswordCheck.retryAfterSeconds || 60),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let ok = false;
|
||||
if (passwordHashB64) {
|
||||
ok = verifySendPasswordHashB64(send, passwordHashB64);
|
||||
} else if (password) {
|
||||
ok = await verifySendPassword(send, password);
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
if (rateLimit && sendPasswordLimitIpKey) {
|
||||
const failed = await rateLimit.recordFailedLogin(sendPasswordLimitIpKey);
|
||||
if (failed.locked) {
|
||||
return {
|
||||
error: sendPasswordLockedOAuthResponse(failed.retryAfterSeconds || 60),
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
error: jsonResponse(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'Invalid password.',
|
||||
send_access_error_type: 'invalid_password',
|
||||
ErrorModel: {
|
||||
Message: 'Invalid password.',
|
||||
Object: 'error',
|
||||
},
|
||||
},
|
||||
400
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (rateLimit && sendPasswordLimitIpKey) {
|
||||
await rateLimit.clearLoginAttempts(sendPasswordLimitIpKey);
|
||||
}
|
||||
}
|
||||
|
||||
const token = await createSendAccessToken(send.id, jwt.secret);
|
||||
return { token };
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types';
|
||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { readActingDeviceIdentifier } from '../utils/device';
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
export const SEND_INACCESSIBLE_MSG = 'Send does not exist or is no longer available';
|
||||
const SEND_PASSWORD_ITERATIONS = 100_000;
|
||||
export const SEND_PASSWORD_LIMIT_SCOPE = 'send-password';
|
||||
|
||||
export async function notifyVaultSyncForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
revisionDate: string
|
||||
): Promise<void> {
|
||||
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
}
|
||||
|
||||
export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } {
|
||||
if (!source || typeof source !== 'object') return { present: false, value: undefined };
|
||||
for (const key of aliases) {
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
const value = (source as Record<string, unknown>)[key];
|
||||
return { present: true, value };
|
||||
}
|
||||
}
|
||||
return { present: false, value: undefined };
|
||||
}
|
||||
|
||||
export function base64UrlEncode(data: Uint8Array): string {
|
||||
const base64 = btoa(String.fromCharCode(...data));
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function base64UrlDecode(input: string): Uint8Array | null {
|
||||
try {
|
||||
let normalized = input.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (normalized.length % 4) normalized += '=';
|
||||
const raw = atob(normalized);
|
||||
const out = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function uuidToBytes(uuid: string): Uint8Array | null {
|
||||
const hex = uuid.replace(/-/g, '').toLowerCase();
|
||||
if (!/^[0-9a-f]{32}$/.test(hex)) return null;
|
||||
const bytes = new Uint8Array(16);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function bytesToUuid(bytes: Uint8Array): string | null {
|
||||
if (bytes.length !== 16) return null;
|
||||
const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
hex.slice(8, 12),
|
||||
hex.slice(12, 16),
|
||||
hex.slice(16, 20),
|
||||
hex.slice(20, 32),
|
||||
].join('-');
|
||||
}
|
||||
|
||||
function toAccessId(sendId: string): string {
|
||||
const bytes = uuidToBytes(sendId);
|
||||
if (!bytes) return '';
|
||||
return base64UrlEncode(bytes);
|
||||
}
|
||||
|
||||
export function fromAccessId(accessId: string): string | null {
|
||||
const bytes = base64UrlDecode(accessId);
|
||||
if (!bytes || bytes.length !== 16) return null;
|
||||
return bytesToUuid(bytes);
|
||||
}
|
||||
|
||||
function isLikelyUuid(value: string): boolean {
|
||||
return /^[a-f0-9-]{36}$/i.test(value);
|
||||
}
|
||||
|
||||
export async function resolveSendFromIdOrAccessId(storage: StorageService, idOrAccessId: string): Promise<Send | null> {
|
||||
if (isLikelyUuid(idOrAccessId)) {
|
||||
const send = await storage.getSend(idOrAccessId);
|
||||
if (send) return send;
|
||||
}
|
||||
|
||||
const sendId = fromAccessId(idOrAccessId);
|
||||
if (!sendId) return null;
|
||||
return storage.getSend(sendId);
|
||||
}
|
||||
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} Bytes`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export function parseDate(raw: unknown): Date | null {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return null;
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date;
|
||||
}
|
||||
|
||||
export function parseInteger(raw: unknown): number | null {
|
||||
if (raw === null || raw === undefined || raw === '') return null;
|
||||
const value = typeof raw === 'string' ? Number(raw) : raw;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function sanitizeSendData(raw: unknown): Record<string, unknown> | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const data = { ...(raw as Record<string, unknown>) };
|
||||
delete data.response;
|
||||
return data;
|
||||
}
|
||||
|
||||
export function parseStoredSendData(send: Send): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(send.data) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return { ...(parsed as Record<string, unknown>) };
|
||||
}
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSendDataSizeField(data: Record<string, unknown>): Record<string, unknown> {
|
||||
const normalized = { ...data };
|
||||
if (typeof normalized.size === 'number' && Number.isFinite(normalized.size)) {
|
||||
normalized.size = String(Math.trunc(normalized.size));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function isSendAvailable(send: Send): boolean {
|
||||
const now = Date.now();
|
||||
|
||||
if (send.maxAccessCount !== null && send.accessCount >= send.maxAccessCount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (send.expirationDate) {
|
||||
const expirationMs = new Date(send.expirationDate).getTime();
|
||||
if (!Number.isNaN(expirationMs) && now >= expirationMs) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const deletionMs = new Date(send.deletionDate).getTime();
|
||||
if (!Number.isNaN(deletionMs) && now >= deletionMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (send.disabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function deriveSendPasswordHash(password: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, ['deriveBits']);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt,
|
||||
iterations,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
key,
|
||||
256
|
||||
);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
diff |= a[i] ^ b[i];
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
function isLikelyHashB64(value: string): boolean {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return false;
|
||||
if (!/^[A-Za-z0-9+/_=-]+$/.test(raw)) return false;
|
||||
const decoded = base64UrlDecode(raw);
|
||||
return !!decoded && decoded.length === 32;
|
||||
}
|
||||
|
||||
export async function setSendPassword(send: Send, password: string | null): Promise<void> {
|
||||
if (!password) {
|
||||
send.passwordHash = null;
|
||||
send.passwordSalt = null;
|
||||
send.passwordIterations = null;
|
||||
if (send.authType === SendAuthType.Password) {
|
||||
send.authType = SendAuthType.None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLikelyHashB64(password)) {
|
||||
send.passwordHash = password.trim();
|
||||
send.passwordSalt = null;
|
||||
send.passwordIterations = null;
|
||||
send.authType = SendAuthType.Password;
|
||||
return;
|
||||
}
|
||||
|
||||
const salt = crypto.getRandomValues(new Uint8Array(64));
|
||||
const hash = await deriveSendPasswordHash(password, salt, SEND_PASSWORD_ITERATIONS);
|
||||
|
||||
send.passwordSalt = base64UrlEncode(salt);
|
||||
send.passwordHash = base64UrlEncode(hash);
|
||||
send.passwordIterations = SEND_PASSWORD_ITERATIONS;
|
||||
send.authType = SendAuthType.Password;
|
||||
}
|
||||
|
||||
export async function verifySendPassword(send: Send, password: string): Promise<boolean> {
|
||||
if (!send.passwordHash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!send.passwordSalt || !send.passwordIterations) {
|
||||
return verifySendPasswordHashB64(send, password);
|
||||
}
|
||||
|
||||
const salt = base64UrlDecode(send.passwordSalt);
|
||||
const expected = base64UrlDecode(send.passwordHash);
|
||||
if (!salt || !expected) return false;
|
||||
|
||||
const actual = await deriveSendPasswordHash(password, salt, send.passwordIterations);
|
||||
return constantTimeEqual(actual, expected);
|
||||
}
|
||||
|
||||
export function verifySendPasswordHashB64(send: Send, passwordHashB64: string): boolean {
|
||||
if (!send.passwordHash || !passwordHashB64) return false;
|
||||
const expected = base64UrlDecode(send.passwordHash);
|
||||
const provided = base64UrlDecode(passwordHashB64);
|
||||
if (!expected || !provided) return false;
|
||||
return constantTimeEqual(expected, provided);
|
||||
}
|
||||
|
||||
export function validateDeletionDate(date: Date): Response | null {
|
||||
const maxMs = Date.now() + LIMITS.send.maxDeletionDays * 24 * 60 * 60 * 1000;
|
||||
if (date.getTime() > maxMs) {
|
||||
return errorResponse(
|
||||
'You cannot have a Send with a deletion date that far into the future. Adjust the Deletion Date to a value less than 31 days from now and try again.',
|
||||
400
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseMaxAccessCount(value: unknown): { ok: true; value: number | null } | { ok: false; response: Response } {
|
||||
const parsed = parseInteger(value);
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return { ok: true, value: null };
|
||||
}
|
||||
if (parsed === null || parsed < 0) {
|
||||
return { ok: false, response: errorResponse('Invalid maxAccessCount', 400) };
|
||||
}
|
||||
return { ok: true, value: parsed };
|
||||
}
|
||||
|
||||
export function parseFileLength(value: unknown): { ok: true; value: number } | { ok: false; response: Response } {
|
||||
const parsed = parseInteger(value);
|
||||
if (parsed === null) {
|
||||
return { ok: false, response: errorResponse('Invalid send length', 400) };
|
||||
}
|
||||
if (parsed < 0) {
|
||||
return { ok: false, response: errorResponse("Send size can't be negative", 400) };
|
||||
}
|
||||
return { ok: true, value: parsed };
|
||||
}
|
||||
|
||||
export function parseSendType(value: unknown): SendType | null {
|
||||
const type = parseInteger(value);
|
||||
if (type === SendType.Text || type === SendType.File) return type;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseSendAuthType(value: unknown): SendAuthType | null {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const parsed = parseInteger(value);
|
||||
if (parsed === SendAuthType.Email || parsed === SendAuthType.Password || parsed === SendAuthType.None) {
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeEmails(value: unknown): string | null {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
if (typeof value === 'string') return value;
|
||||
if (Array.isArray(value)) {
|
||||
const strings = value.filter((v) => typeof v === 'string').map((v) => String(v));
|
||||
if (strings.length === 0) return null;
|
||||
return strings.join(',');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasEmailAuth(send: Send): boolean {
|
||||
return send.authType === SendAuthType.Email;
|
||||
}
|
||||
|
||||
export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
||||
return { ok: false, response: errorResponse('Server configuration error', 500) };
|
||||
}
|
||||
return { ok: true, secret };
|
||||
}
|
||||
|
||||
export function extractBearerToken(request: Request): string | null {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader) return null;
|
||||
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
export function sendToResponse(send: Send): SendResponse {
|
||||
const data = normalizeSendDataSizeField(parseStoredSendData(send));
|
||||
return {
|
||||
id: send.id,
|
||||
accessId: toAccessId(send.id),
|
||||
type: Number(send.type) || 0,
|
||||
name: send.name,
|
||||
notes: send.notes,
|
||||
text: send.type === SendType.Text ? data : null,
|
||||
file: send.type === SendType.File ? data : null,
|
||||
key: send.key,
|
||||
maxAccessCount: send.maxAccessCount,
|
||||
accessCount: send.accessCount,
|
||||
password: send.passwordHash,
|
||||
emails: send.emails,
|
||||
authType: send.authType,
|
||||
disabled: send.disabled,
|
||||
hideEmail: send.hideEmail,
|
||||
revisionDate: send.updatedAt,
|
||||
expirationDate: send.expirationDate,
|
||||
deletionDate: send.deletionDate,
|
||||
object: 'send',
|
||||
};
|
||||
}
|
||||
|
||||
export function sendToAccessResponse(send: Send, creatorIdentifier: string | null): Record<string, unknown> {
|
||||
const data = normalizeSendDataSizeField(parseStoredSendData(send));
|
||||
return {
|
||||
id: send.id,
|
||||
type: Number(send.type) || 0,
|
||||
name: send.name,
|
||||
text: send.type === SendType.Text ? data : null,
|
||||
file: send.type === SendType.File ? data : null,
|
||||
expirationDate: send.expirationDate,
|
||||
deletionDate: send.deletionDate,
|
||||
creatorIdentifier,
|
||||
object: 'send-access',
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCreatorIdentifier(storage: StorageService, send: Send): Promise<string | null> {
|
||||
if (send.hideEmail) return null;
|
||||
const owner = await storage.getUserById(send.userId);
|
||||
return owner?.email ?? null;
|
||||
}
|
||||
|
||||
export type PublicSendAccessValidationResult =
|
||||
| { ok: true }
|
||||
| { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' };
|
||||
|
||||
export function sendPasswordLimitKey(clientIdentifier: string): string {
|
||||
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}`;
|
||||
}
|
||||
|
||||
function sendPasswordLockMessage(retryAfterSeconds: number): string {
|
||||
return `Too many failed send password attempts. Try again in ${Math.ceil(retryAfterSeconds / 60)} minutes.`;
|
||||
}
|
||||
|
||||
export function sendPasswordLockedErrorResponse(retryAfterSeconds: number): Response {
|
||||
return errorResponse(sendPasswordLockMessage(retryAfterSeconds), 429);
|
||||
}
|
||||
|
||||
export function sendPasswordLockedOAuthResponse(retryAfterSeconds: number): Response {
|
||||
const message = sendPasswordLockMessage(retryAfterSeconds);
|
||||
return jsonResponse(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: message,
|
||||
send_access_error_type: 'too_many_password_attempts',
|
||||
ErrorModel: {
|
||||
Message: message,
|
||||
Object: 'error',
|
||||
},
|
||||
},
|
||||
429
|
||||
);
|
||||
}
|
||||
|
||||
export async function validatePublicSendAccess(send: Send, body: unknown): Promise<PublicSendAccessValidationResult> {
|
||||
if (hasEmailAuth(send)) {
|
||||
return { ok: false, response: errorResponse(SEND_INACCESSIBLE_MSG, 404), reason: 'email_auth_unsupported' };
|
||||
}
|
||||
|
||||
if (!send.passwordHash) return { ok: true };
|
||||
|
||||
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
|
||||
const passwordHashB64Raw = getAliasedProp(body, [
|
||||
'password_hash_b64',
|
||||
'passwordHashB64',
|
||||
'passwordHash',
|
||||
'password_hash',
|
||||
]);
|
||||
|
||||
let validPassword = false;
|
||||
if (send.passwordSalt && send.passwordIterations) {
|
||||
if (typeof passwordRaw.value !== 'string') {
|
||||
return { ok: false, response: errorResponse('Password not provided', 401), reason: 'password_missing' };
|
||||
}
|
||||
validPassword = await verifySendPassword(send, passwordRaw.value);
|
||||
} else {
|
||||
const candidate =
|
||||
typeof passwordHashB64Raw.value === 'string'
|
||||
? passwordHashB64Raw.value
|
||||
: typeof passwordRaw.value === 'string'
|
||||
? passwordRaw.value
|
||||
: '';
|
||||
if (!candidate) return { ok: false, response: errorResponse('Password not provided', 401), reason: 'password_missing' };
|
||||
validPassword = verifySendPasswordHashB64(send, candidate);
|
||||
}
|
||||
if (!validPassword) {
|
||||
return { ok: false, response: errorResponse('Invalid password', 400), reason: 'invalid_password' };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
+3
-1473
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,276 @@
|
||||
import type { Env, User } from './types';
|
||||
import { errorResponse, jsonResponse } from './utils/response';
|
||||
import {
|
||||
handleGetProfile,
|
||||
handleSetKeys,
|
||||
handleGetRevisionDate,
|
||||
handleVerifyPassword,
|
||||
handleChangePassword,
|
||||
handleGetTotpStatus,
|
||||
handleSetTotpStatus,
|
||||
handleGetTotpRecoveryCode,
|
||||
} from './handlers/accounts';
|
||||
import {
|
||||
handleGetCiphers,
|
||||
handleGetCipher,
|
||||
handleCreateCipher,
|
||||
handleUpdateCipher,
|
||||
handleDeleteCipher,
|
||||
handleDeleteCipherCompat,
|
||||
handlePermanentDeleteCipher,
|
||||
handleRestoreCipher,
|
||||
handlePartialUpdateCipher,
|
||||
handleBulkMoveCiphers,
|
||||
handleBulkDeleteCiphers,
|
||||
handleBulkPermanentDeleteCiphers,
|
||||
handleBulkRestoreCiphers,
|
||||
} from './handlers/ciphers';
|
||||
import {
|
||||
handleGetFolders,
|
||||
handleGetFolder,
|
||||
handleCreateFolder,
|
||||
handleUpdateFolder,
|
||||
handleDeleteFolder,
|
||||
handleBulkDeleteFolders,
|
||||
} from './handlers/folders';
|
||||
import {
|
||||
handleGetSends,
|
||||
handleGetSend,
|
||||
handleCreateSend,
|
||||
handleGetSendFileUpload,
|
||||
handleUploadSendFile,
|
||||
handleUpdateSend,
|
||||
handleDeleteSend,
|
||||
handleBulkDeleteSends,
|
||||
handleRemoveSendPassword,
|
||||
handleRemoveSendAuth,
|
||||
} from './handlers/sends';
|
||||
import { handleSync } from './handlers/sync';
|
||||
import { handleCiphersImport } from './handlers/import';
|
||||
import {
|
||||
handleCreateAttachment,
|
||||
handleUploadAttachment,
|
||||
handleGetAttachment,
|
||||
handleDeleteAttachment,
|
||||
} from './handlers/attachments';
|
||||
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
||||
import { handleAdminRoute } from './router-admin';
|
||||
|
||||
export async function handleAuthenticatedRoute(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
currentUser: User,
|
||||
path: string,
|
||||
method: string
|
||||
): Promise<Response | null> {
|
||||
if (method === 'POST' || method === 'PUT' || method === 'DELETE') {
|
||||
const blockedAccountPaths = new Set([
|
||||
'/api/accounts/set-password',
|
||||
'/api/accounts/delete',
|
||||
'/api/accounts/delete-account',
|
||||
'/api/accounts/delete-vault',
|
||||
]);
|
||||
if (blockedAccountPaths.has(path)) {
|
||||
return errorResponse('Not implemented', 501);
|
||||
}
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/profile') {
|
||||
if (method === 'GET') return handleGetProfile(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if ((path === '/api/accounts/password' || path === '/api/accounts/change-password') && (method === 'POST' || method === 'PUT')) {
|
||||
return handleChangePassword(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/keys' && method === 'POST') {
|
||||
return handleSetKeys(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/totp') {
|
||||
if (method === 'GET') return handleGetTotpStatus(request, env, userId);
|
||||
if (method === 'PUT' || method === 'POST') return handleSetTotpStatus(request, env, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((path === '/api/accounts/totp/recovery-code' || path === '/api/two-factor/get-recover') && method === 'POST') {
|
||||
return handleGetTotpRecoveryCode(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/revision-date' && method === 'GET') {
|
||||
return handleGetRevisionDate(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/verify-password' && method === 'POST') {
|
||||
return handleVerifyPassword(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/sync' && method === 'GET') {
|
||||
return handleSync(request, env, userId);
|
||||
}
|
||||
|
||||
if (path.startsWith('/notifications/')) {
|
||||
return errorResponse('Not found', 404);
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers' || path === '/api/ciphers/create') {
|
||||
if (method === 'GET') return handleGetCiphers(request, env, userId);
|
||||
if (method === 'POST') return handleCreateCipher(request, env, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers/import' && method === 'POST') {
|
||||
return handleCiphersImport(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers/delete' && method === 'POST') {
|
||||
return handleBulkDeleteCiphers(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers/delete-permanent' && method === 'POST') {
|
||||
return handleBulkPermanentDeleteCiphers(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers/restore' && method === 'POST') {
|
||||
return handleBulkRestoreCiphers(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers/move' && (method === 'POST' || method === 'PUT')) {
|
||||
return handleBulkMoveCiphers(request, env, userId);
|
||||
}
|
||||
|
||||
const cipherMatch = path.match(/^\/api\/ciphers\/([a-f0-9-]+)(\/.*)?$/i);
|
||||
if (cipherMatch) {
|
||||
const cipherId = cipherMatch[1];
|
||||
const subPath = cipherMatch[2] || '';
|
||||
|
||||
if (subPath === '' || subPath === '/') {
|
||||
if (method === 'GET') return handleGetCipher(request, env, userId, cipherId);
|
||||
if (method === 'PUT' || method === 'POST') return handleUpdateCipher(request, env, userId, cipherId);
|
||||
if (method === 'DELETE') return handleDeleteCipherCompat(request, env, userId, cipherId);
|
||||
}
|
||||
|
||||
if (subPath === '/delete' && method === 'PUT') return handleDeleteCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/delete' && method === 'DELETE') return handlePermanentDeleteCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/restore' && method === 'PUT') return handleRestoreCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) return handlePartialUpdateCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/share' && method === 'POST') return handleGetCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/details' && method === 'GET') return handleGetCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/attachment/v2' && method === 'POST') return handleCreateAttachment(request, env, userId, cipherId);
|
||||
if (subPath === '/attachment' && method === 'POST') return handleCreateAttachment(request, env, userId, cipherId);
|
||||
|
||||
const attachmentMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)$/i);
|
||||
if (attachmentMatch) {
|
||||
const attachmentId = attachmentMatch[1];
|
||||
if (method === 'POST') return handleUploadAttachment(request, env, userId, cipherId, attachmentId);
|
||||
if (method === 'GET') return handleGetAttachment(request, env, userId, cipherId, attachmentId);
|
||||
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
|
||||
}
|
||||
|
||||
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
|
||||
if (attachmentDeleteMatch && method === 'POST') {
|
||||
return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (path === '/api/folders') {
|
||||
if (method === 'GET') return handleGetFolders(request, env, userId);
|
||||
if (method === 'POST') return handleCreateFolder(request, env, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path === '/api/folders/delete' && method === 'POST') {
|
||||
return handleBulkDeleteFolders(request, env, userId);
|
||||
}
|
||||
|
||||
const folderMatch = path.match(/^\/api\/folders\/([a-f0-9-]+)$/i);
|
||||
if (folderMatch) {
|
||||
const folderId = folderMatch[1];
|
||||
if (method === 'GET') return handleGetFolder(request, env, userId, folderId);
|
||||
if (method === 'PUT') return handleUpdateFolder(request, env, userId, folderId);
|
||||
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
|
||||
}
|
||||
|
||||
if (path.startsWith('/api/auth-requests')) {
|
||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||
}
|
||||
|
||||
if (path === '/api/collections' || path.startsWith('/api/collections/')) {
|
||||
if (method === 'GET') {
|
||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path === '/api/organizations' || path.startsWith('/api/organizations/')) {
|
||||
if (method === 'GET') {
|
||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path === '/api/sends') {
|
||||
if (method === 'GET') return handleGetSends(request, env, userId);
|
||||
if (method === 'POST') return handleCreateSend(request, env, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path === '/api/sends/delete' && method === 'POST') {
|
||||
return handleBulkDeleteSends(request, env, userId);
|
||||
}
|
||||
|
||||
const sendMatch = path.match(/^\/api\/sends\/([^/]+)(\/.*)?$/i);
|
||||
if (sendMatch) {
|
||||
const sendId = sendMatch[1];
|
||||
const subPath = sendMatch[2] || '';
|
||||
|
||||
if (subPath === '' || subPath === '/') {
|
||||
if (method === 'GET') return handleGetSend(request, env, userId, sendId);
|
||||
if (method === 'PUT') return handleUpdateSend(request, env, userId, sendId);
|
||||
if (method === 'DELETE') return handleDeleteSend(request, env, userId, sendId);
|
||||
}
|
||||
|
||||
if (subPath === '/remove-password' && (method === 'PUT' || method === 'POST')) {
|
||||
return handleRemoveSendPassword(request, env, userId, sendId);
|
||||
}
|
||||
|
||||
if (subPath === '/remove-auth' && (method === 'PUT' || method === 'POST')) {
|
||||
return handleRemoveSendAuth(request, env, userId, sendId);
|
||||
}
|
||||
|
||||
const sendFileUploadMatch = subPath.match(/^\/file\/([^/]+)\/?$/i);
|
||||
if (sendFileUploadMatch) {
|
||||
const fileId = sendFileUploadMatch[1];
|
||||
if (method === 'GET') return handleGetSendFileUpload(request, env, userId, sendId, fileId);
|
||||
if (method === 'POST' || method === 'PUT') return handleUploadSendFile(request, env, userId, sendId, fileId);
|
||||
}
|
||||
}
|
||||
|
||||
if (path === '/api/policies' || path.startsWith('/api/policies/')) {
|
||||
if (method === 'GET') {
|
||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path === '/api/settings/domains') {
|
||||
if (method === 'GET' || method === 'PUT' || method === 'POST') {
|
||||
return jsonResponse({
|
||||
equivalentDomains: [],
|
||||
globalEquivalentDomains: [],
|
||||
object: 'domains',
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const authenticatedDeviceResponse = await handleAuthenticatedDeviceRoute(request, env, userId, path, method);
|
||||
if (authenticatedDeviceResponse) return authenticatedDeviceResponse;
|
||||
|
||||
const adminResponse = await handleAdminRoute(request, env, currentUser, path, method);
|
||||
if (adminResponse) return adminResponse;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { LIMITS } from './config/limits';
|
||||
import { DEFAULT_DEV_SECRET } from './types';
|
||||
import {
|
||||
handleAccessSend,
|
||||
handleAccessSendFile,
|
||||
handleAccessSendV2,
|
||||
handleAccessSendFileV2,
|
||||
handleDownloadSendFile,
|
||||
} from './handlers/sends';
|
||||
import { handleSetupStatus } from './handlers/setup';
|
||||
import { handleKnownDevice } from './handlers/devices';
|
||||
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
|
||||
import {
|
||||
handleRegister,
|
||||
handleRecoverTwoFactor,
|
||||
} from './handlers/accounts';
|
||||
import { handlePublicDownloadAttachment } from './handlers/attachments';
|
||||
import {
|
||||
handleNotificationsHub,
|
||||
handleNotificationsNegotiate,
|
||||
} from './handlers/notifications';
|
||||
import { jsonResponse } from './utils/response';
|
||||
import type { Env } from './types';
|
||||
|
||||
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
function handleNwFavicon(): Response {
|
||||
return new Response(getNwIconSvg(), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml; charset=utf-8',
|
||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isValidIconHostname(hostname: string): boolean {
|
||||
if (!hostname) return false;
|
||||
if (hostname.length > 253) return false;
|
||||
|
||||
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
||||
const domainPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})$/;
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGetIcon(env: Env, hostname: string): Promise<Response> {
|
||||
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;
|
||||
|
||||
const resp = await fetch(`https://favicon.im/${normalizedHostname}`, {
|
||||
headers: { 'User-Agent': 'NodeWarden/1.0' },
|
||||
redirect: 'follow',
|
||||
cf: {
|
||||
cacheEverything: true,
|
||||
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
||||
},
|
||||
});
|
||||
|
||||
if (!resp.ok) return new Response(null, { status: 204 });
|
||||
|
||||
const body = await resp.arrayBuffer();
|
||||
if (body.byteLength === 0) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
const iconResponse = new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
||||
},
|
||||
});
|
||||
await cache.put(cacheKey, iconResponse.clone());
|
||||
return iconResponse;
|
||||
} catch {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWebConfigResponse(env: Env) {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
const jwtUnsafeReason =
|
||||
!secret
|
||||
? 'missing'
|
||||
: secret === DEFAULT_DEV_SECRET
|
||||
? 'default'
|
||||
: secret.length < LIMITS.auth.jwtSecretMinLength
|
||||
? 'too_short'
|
||||
: null;
|
||||
|
||||
return {
|
||||
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
|
||||
jwtUnsafeReason,
|
||||
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handlePublicRoute(
|
||||
request: Request,
|
||||
env: Env,
|
||||
path: string,
|
||||
method: string,
|
||||
enforcePublicRateLimit: PublicRateLimiter
|
||||
): Promise<Response | null> {
|
||||
if (path === '/setup/status' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleSetupStatus(request, env);
|
||||
}
|
||||
|
||||
if (path === '/api/web/config' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return jsonResponse(buildWebConfigResponse(env));
|
||||
}
|
||||
|
||||
if (path === '/.well-known/appspecific/com.chrome.devtools.json' && method === 'GET') {
|
||||
return new Response('{}', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if ((path === '/favicon.ico' || path === '/favicon.svg') && method === 'GET') {
|
||||
return handleNwFavicon();
|
||||
}
|
||||
|
||||
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||
if (iconMatch) {
|
||||
return handleGetIcon(env, iconMatch[1]);
|
||||
}
|
||||
|
||||
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
||||
if (publicAttachmentMatch && method === 'GET') {
|
||||
return handlePublicDownloadAttachment(request, env, publicAttachmentMatch[1], publicAttachmentMatch[2]);
|
||||
}
|
||||
|
||||
const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i);
|
||||
if (sendAccessMatch && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return blocked;
|
||||
return handleAccessSend(request, env, sendAccessMatch[1]);
|
||||
}
|
||||
|
||||
if (path === '/api/sends/access' && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return blocked;
|
||||
return handleAccessSendV2(request, env);
|
||||
}
|
||||
|
||||
const sendAccessFileV2Match = path.match(/^\/api\/sends\/access\/file\/([^/]+)\/?$/i);
|
||||
if (sendAccessFileV2Match && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return blocked;
|
||||
return handleAccessSendFileV2(request, env, sendAccessFileV2Match[1]);
|
||||
}
|
||||
|
||||
const sendAccessFileMatch = path.match(/^\/api\/sends\/([^/]+)\/access\/file\/([^/]+)\/?$/i);
|
||||
if (sendAccessFileMatch && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return blocked;
|
||||
return handleAccessSendFile(request, env, sendAccessFileMatch[1], sendAccessFileMatch[2]);
|
||||
}
|
||||
|
||||
const sendDownloadMatch = path.match(/^\/api\/sends\/([^/]+)\/([^/]+)\/?$/i);
|
||||
if (sendDownloadMatch && method === 'GET') {
|
||||
return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]);
|
||||
}
|
||||
|
||||
if (path === '/identity/connect/token' && method === 'POST') {
|
||||
return handleToken(request, env);
|
||||
}
|
||||
|
||||
if (path === '/api/devices/knowndevice' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return jsonResponse(false);
|
||||
return handleKnownDevice(request, env);
|
||||
}
|
||||
|
||||
if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleRevocation(request, env);
|
||||
}
|
||||
|
||||
if (path === '/identity/accounts/prelogin' && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handlePrelogin(request, env);
|
||||
}
|
||||
|
||||
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
|
||||
return handleRecoverTwoFactor(request, env);
|
||||
}
|
||||
|
||||
if ((path === '/config' || path === '/api/config') && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
const origin = new URL(request.url).origin;
|
||||
return jsonResponse({
|
||||
version: LIMITS.compatibility.bitwardenServerVersion,
|
||||
gitHash: 'nodewarden',
|
||||
server: null,
|
||||
environment: {
|
||||
vault: origin,
|
||||
api: origin + '/api',
|
||||
identity: origin + '/identity',
|
||||
notifications: origin + '/notifications',
|
||||
sso: '',
|
||||
},
|
||||
featureStates: {
|
||||
'duo-redirect': true,
|
||||
'email-verification': true,
|
||||
'pm-19051-send-email-verification': false,
|
||||
'unauth-ui-refresh': true,
|
||||
},
|
||||
object: 'config',
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/version' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return jsonResponse(LIMITS.compatibility.bitwardenServerVersion);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/register' && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('register', LIMITS.rateLimit.registerRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
if (!isSameOriginWriteRequest(request)) {
|
||||
return new Response(JSON.stringify({ error: 'Forbidden origin' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return handleRegister(request, env);
|
||||
}
|
||||
|
||||
if (path === '/notifications/hub/negotiate' && method === 'POST') {
|
||||
return handleNotificationsNegotiate(request, env);
|
||||
}
|
||||
|
||||
if (path === '/notifications/hub' && method === 'GET') {
|
||||
return handleNotificationsHub(request, env);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+52
-692
@@ -1,120 +1,11 @@
|
||||
import { Env, DEFAULT_DEV_SECRET } from './types';
|
||||
import { DEFAULT_DEV_SECRET, Env } from './types';
|
||||
import { AuthService } from './services/auth';
|
||||
import { StorageService } from './services/storage';
|
||||
import { RateLimitService, getClientIdentifier } from './services/ratelimit';
|
||||
import { handleCors, errorResponse, jsonResponse } from './utils/response';
|
||||
import { handleCors, errorResponse } from './utils/response';
|
||||
import { LIMITS } from './config/limits';
|
||||
|
||||
// Identity handlers
|
||||
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
|
||||
|
||||
// Account handlers
|
||||
import {
|
||||
handleRegister,
|
||||
handleGetProfile,
|
||||
handleSetKeys,
|
||||
handleGetRevisionDate,
|
||||
handleVerifyPassword,
|
||||
handleChangePassword,
|
||||
handleGetTotpStatus,
|
||||
handleSetTotpStatus,
|
||||
handleGetTotpRecoveryCode,
|
||||
handleRecoverTwoFactor,
|
||||
} from './handlers/accounts';
|
||||
|
||||
// Cipher handlers
|
||||
import {
|
||||
handleGetCiphers,
|
||||
handleGetCipher,
|
||||
handleCreateCipher,
|
||||
handleUpdateCipher,
|
||||
handleDeleteCipher,
|
||||
handleDeleteCipherCompat,
|
||||
handlePermanentDeleteCipher,
|
||||
handleRestoreCipher,
|
||||
handlePartialUpdateCipher,
|
||||
handleBulkMoveCiphers,
|
||||
handleBulkDeleteCiphers,
|
||||
handleBulkPermanentDeleteCiphers,
|
||||
handleBulkRestoreCiphers,
|
||||
} from './handlers/ciphers';
|
||||
|
||||
// Folder handlers
|
||||
import {
|
||||
handleGetFolders,
|
||||
handleGetFolder,
|
||||
handleCreateFolder,
|
||||
handleUpdateFolder,
|
||||
handleDeleteFolder,
|
||||
handleBulkDeleteFolders,
|
||||
} from './handlers/folders';
|
||||
|
||||
// Send handlers
|
||||
import {
|
||||
handleGetSends,
|
||||
handleGetSend,
|
||||
handleCreateSend,
|
||||
handleCreateFileSendV2,
|
||||
handleGetSendFileUpload,
|
||||
handleUploadSendFile,
|
||||
handleUpdateSend,
|
||||
handleDeleteSend,
|
||||
handleBulkDeleteSends,
|
||||
handleRemoveSendPassword,
|
||||
handleRemoveSendAuth,
|
||||
handleAccessSend,
|
||||
handleAccessSendFile,
|
||||
handleAccessSendV2,
|
||||
handleAccessSendFileV2,
|
||||
handleDownloadSendFile,
|
||||
} from './handlers/sends';
|
||||
|
||||
// Sync handler
|
||||
import { handleSync } from './handlers/sync';
|
||||
|
||||
// Setup handlers
|
||||
import { handleSetupStatus } from './handlers/setup';
|
||||
import {
|
||||
handleKnownDevice,
|
||||
} from './handlers/devices';
|
||||
|
||||
// Import handler
|
||||
import { handleCiphersImport } from './handlers/import';
|
||||
|
||||
// Attachment handlers
|
||||
import {
|
||||
handleCreateAttachment,
|
||||
handleUploadAttachment,
|
||||
handleGetAttachment,
|
||||
handleDeleteAttachment,
|
||||
handlePublicDownloadAttachment,
|
||||
} from './handlers/attachments';
|
||||
import {
|
||||
handleNotificationsHub,
|
||||
handleNotificationsNegotiate,
|
||||
} from './handlers/notifications';
|
||||
import { handleAdminRoute } from './router-admin';
|
||||
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
||||
|
||||
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;
|
||||
}
|
||||
import { handleAuthenticatedRoute } from './router-authenticated';
|
||||
import { handlePublicRoute } from './router-public';
|
||||
|
||||
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
@@ -124,10 +15,6 @@ function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' |
|
||||
return null;
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
function isImportBypassRequest(request: Request, path: string, method: string): boolean {
|
||||
if (request.headers.get('X-NodeWarden-Import') !== '1') return false;
|
||||
|
||||
@@ -140,85 +27,6 @@ function isImportBypassRequest(request: Request, path: string, method: string):
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleNwFavicon(): Response {
|
||||
return new Response(getNwIconSvg(), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml; charset=utf-8',
|
||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isValidIconHostname(hostname: string): boolean {
|
||||
if (!hostname) return false;
|
||||
if (hostname.length > 253) return false;
|
||||
|
||||
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
||||
// Slightly relaxed domain validation:
|
||||
// - keep strict label boundaries (no leading/trailing hyphen)
|
||||
// - allow punycode TLD (e.g. xn--...)
|
||||
const domainPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})$/;
|
||||
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 favicon.im
|
||||
async function handleGetIcon(request: Request, env: Env, hostname: string): Promise<Response> {
|
||||
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;
|
||||
}
|
||||
|
||||
const iconUrl = `https://favicon.im/${normalizedHostname}`;
|
||||
const resp = await fetch(iconUrl, {
|
||||
headers: { 'User-Agent': 'NodeWarden/1.0' },
|
||||
redirect: 'follow',
|
||||
cf: {
|
||||
cacheEverything: true,
|
||||
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
||||
},
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
const body = await resp.arrayBuffer();
|
||||
if (body.byteLength === 0) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
const iconResponse = new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`, // 7 days
|
||||
},
|
||||
});
|
||||
await cache.put(cacheKey, iconResponse.clone());
|
||||
return iconResponse;
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
} catch {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleRequest(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
@@ -230,42 +38,43 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
maxRequests: number = LIMITS.rateLimit.publicRequestsPerMinute
|
||||
): Promise<Response | null> {
|
||||
if (!clientId) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Forbidden',
|
||||
error_description: 'Client IP is required',
|
||||
}), {
|
||||
status: 403,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Forbidden',
|
||||
error_description: 'Client IP is required',
|
||||
}),
|
||||
{
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
const check = await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests);
|
||||
if (check.allowed) return null;
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Too many requests',
|
||||
error_description: `Rate limit exceeded. Try again in ${check.retryAfterSeconds} seconds.`,
|
||||
}), {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': String(check.retryAfterSeconds || 60),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Too many requests',
|
||||
error_description: `Rate limit exceeded. Try again in ${check.retryAfterSeconds} seconds.`,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': String(check.retryAfterSeconds || 60),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Handle CORS preflight
|
||||
if (method === 'OPTIONS') {
|
||||
return handleCors(request);
|
||||
}
|
||||
|
||||
// Route matching
|
||||
try {
|
||||
|
||||
// Reject oversized bodies before any path-specific parsing.
|
||||
// Large file/archive upload paths enforce their own limits and are exempt here.
|
||||
const isLargeUploadPath =
|
||||
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) ||
|
||||
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) ||
|
||||
@@ -277,207 +86,17 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
}
|
||||
}
|
||||
|
||||
// Setup status
|
||||
if (path === '/setup/status' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleSetupStatus(request, env);
|
||||
}
|
||||
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
|
||||
if (publicResponse) return publicResponse;
|
||||
|
||||
// Web runtime config for static client bootstrap
|
||||
if (path === '/api/web/config' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
const jwtUnsafeReason = jwtSecretUnsafeReason(env);
|
||||
return jsonResponse({
|
||||
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
|
||||
jwtUnsafeReason,
|
||||
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
||||
});
|
||||
}
|
||||
|
||||
// Browser/devtools probe endpoint
|
||||
if (path === '/.well-known/appspecific/com.chrome.devtools.json' && method === 'GET') {
|
||||
return new Response('{}', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Favicon
|
||||
if ((path === '/favicon.ico' || path === '/favicon.svg') && method === 'GET') {
|
||||
return handleNwFavicon();
|
||||
}
|
||||
|
||||
// Icon endpoint - proxy to Bitwarden's icon service (no auth required)
|
||||
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||
if (iconMatch) {
|
||||
const hostname = iconMatch[1];
|
||||
return handleGetIcon(request, env, hostname);
|
||||
}
|
||||
|
||||
// Public attachment download (no auth header, uses token in query string)
|
||||
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
||||
if (publicAttachmentMatch && method === 'GET') {
|
||||
const cipherId = publicAttachmentMatch[1];
|
||||
const attachmentId = publicAttachmentMatch[2];
|
||||
return handlePublicDownloadAttachment(request, env, cipherId, attachmentId);
|
||||
}
|
||||
|
||||
// Public Send access endpoints
|
||||
const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i);
|
||||
if (sendAccessMatch && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return blocked;
|
||||
const accessId = sendAccessMatch[1];
|
||||
return handleAccessSend(request, env, accessId);
|
||||
}
|
||||
|
||||
const sendAccessV2Match = path === '/api/sends/access';
|
||||
if (sendAccessV2Match && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return blocked;
|
||||
return handleAccessSendV2(request, env);
|
||||
}
|
||||
|
||||
const sendAccessFileV2Match = path.match(/^\/api\/sends\/access\/file\/([^/]+)\/?$/i);
|
||||
if (sendAccessFileV2Match && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return blocked;
|
||||
const fileId = sendAccessFileV2Match[1];
|
||||
return handleAccessSendFileV2(request, env, fileId);
|
||||
}
|
||||
|
||||
const sendAccessFileMatch = path.match(/^\/api\/sends\/([^/]+)\/access\/file\/([^/]+)\/?$/i);
|
||||
if (sendAccessFileMatch && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return blocked;
|
||||
const idOrAccessId = sendAccessFileMatch[1];
|
||||
const fileId = sendAccessFileMatch[2];
|
||||
return handleAccessSendFile(request, env, idOrAccessId, fileId);
|
||||
}
|
||||
|
||||
const sendDownloadMatch = path.match(/^\/api\/sends\/([^/]+)\/([^/]+)\/?$/i);
|
||||
if (sendDownloadMatch && method === 'GET') {
|
||||
const sendId = sendDownloadMatch[1];
|
||||
const fileId = sendDownloadMatch[2];
|
||||
return handleDownloadSendFile(request, env, sendId, fileId);
|
||||
}
|
||||
|
||||
// Identity endpoints (no auth required)
|
||||
if (path === '/identity/connect/token' && method === 'POST') {
|
||||
return handleToken(request, env);
|
||||
}
|
||||
|
||||
// Known device check (no auth required).
|
||||
if (path === '/api/devices/knowndevice' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return jsonResponse(false);
|
||||
return handleKnownDevice(request, env);
|
||||
}
|
||||
|
||||
if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleRevocation(request, env);
|
||||
}
|
||||
|
||||
if (path === '/identity/accounts/prelogin' && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handlePrelogin(request, env);
|
||||
}
|
||||
|
||||
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
|
||||
return handleRecoverTwoFactor(request, env);
|
||||
}
|
||||
|
||||
// Config endpoint (no auth required for basic config)
|
||||
// Bitwarden clients call GET "/config" (relative to the API base URL).
|
||||
// They also tolerate different casing, but their response models use PascalCase.
|
||||
const isConfigRequest = (path === '/config' || path === '/api/config') && method === 'GET';
|
||||
if (isConfigRequest) {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
const origin = url.origin;
|
||||
return jsonResponse({
|
||||
// ── Version Strategy (Plan E) ──────────────────────────────────────
|
||||
// Bitwarden clients use this version for backwards-compatibility feature gating.
|
||||
// Confirmed version-gated features (from client source code):
|
||||
// - Individual cipher key encryption: >= 2024.2.0
|
||||
// (clients/libs/common/src/vault/services/cipher.service.ts: CIPHER_KEY_ENC_MIN_SERVER_VER)
|
||||
// (android/.../FeatureFlagManagerImpl.kt: CIPHER_KEY_ENC_MIN_SERVER_VERSION)
|
||||
// - MasterPasswordUnlockData (mobile): >= 2025.8.0
|
||||
// (documented in Vaultwarden source comments)
|
||||
// There is NO global minimum version that blocks all client functionality.
|
||||
// Keep this aligned with Vaultwarden's reported version to maintain compatibility.
|
||||
// When Vaultwarden bumps their version, update this value accordingly.
|
||||
// Vaultwarden source: src/api/core/mod.rs → fn config()
|
||||
version: LIMITS.compatibility.bitwardenServerVersion,
|
||||
gitHash: 'nodewarden',
|
||||
server: null,
|
||||
environment: {
|
||||
vault: origin,
|
||||
api: origin + '/api',
|
||||
identity: origin + '/identity',
|
||||
notifications: origin + '/notifications',
|
||||
sso: '',
|
||||
},
|
||||
// Feature flags control client behavior. Clients use server-provided values;
|
||||
// flags not listed here fall back to DefaultFeatureFlagValue (all false).
|
||||
// Only enable flags for features we actually support.
|
||||
// Reference: clients/libs/common/src/enums/feature-flag.enum.ts
|
||||
featureStates: {
|
||||
'duo-redirect': true,
|
||||
'email-verification': true,
|
||||
'pm-19051-send-email-verification': false,
|
||||
'unauth-ui-refresh': true,
|
||||
},
|
||||
object: 'config',
|
||||
});
|
||||
}
|
||||
|
||||
// Version endpoint (some clients probe this to validate the server)
|
||||
if (path === '/api/version' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return jsonResponse(LIMITS.compatibility.bitwardenServerVersion); // Always same value as /config.version
|
||||
}
|
||||
|
||||
// Registration endpoint (no auth required):
|
||||
// - first user can self-register and becomes admin
|
||||
// - later registrations require inviteCode in request body
|
||||
if (path === '/api/accounts/register' && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('register', LIMITS.rateLimit.registerRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
if (!isSameOriginWriteRequest(request)) {
|
||||
return errorResponse('Forbidden origin', 403);
|
||||
}
|
||||
return handleRegister(request, env);
|
||||
}
|
||||
|
||||
// If JWT_SECRET is not safely configured, block any other endpoints.
|
||||
const secret = jwtSecretUnsafeReason(env);
|
||||
if (secret) {
|
||||
const secretIssue = jwtSecretUnsafeReason(env);
|
||||
if (secretIssue) {
|
||||
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
|
||||
}
|
||||
|
||||
if (path === '/notifications/hub/negotiate' && method === 'POST') {
|
||||
return handleNotificationsNegotiate(request, env);
|
||||
}
|
||||
|
||||
if (path === '/notifications/hub' && method === 'GET') {
|
||||
return handleNotificationsHub(request, env);
|
||||
}
|
||||
|
||||
// All other API endpoints require authentication
|
||||
const auth = new AuthService(env);
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
const payload = await auth.verifyAccessToken(authHeader);
|
||||
|
||||
if (!payload) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
@@ -498,291 +117,32 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
if (currentUser.status !== 'active') {
|
||||
return errorResponse('Account is disabled', 403);
|
||||
}
|
||||
// Unified rate limiting for all authenticated API requests.
|
||||
|
||||
if (!isImportBypassRequest(request, path, method)) {
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
const rateLimitCheck = await rateLimit.consumeBudget(
|
||||
userId + ':api',
|
||||
LIMITS.rateLimit.apiRequestsPerMinute
|
||||
);
|
||||
|
||||
const rateLimitCheck = await rateLimit.consumeBudget(`${userId}:api`, LIMITS.rateLimit.apiRequestsPerMinute);
|
||||
if (!rateLimitCheck.allowed) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Too many requests',
|
||||
error_description: `Rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
|
||||
}), {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
},
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Too many requests',
|
||||
error_description: `Rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': String(rateLimitCheck.retryAfterSeconds || 60),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Block account operations we do not support yet.
|
||||
if (method === 'POST' || method === 'PUT' || method === 'DELETE') {
|
||||
const blockedAccountPaths = new Set([
|
||||
'/api/accounts/set-password',
|
||||
'/api/accounts/delete',
|
||||
'/api/accounts/delete-account',
|
||||
'/api/accounts/delete-vault',
|
||||
]);
|
||||
if (blockedAccountPaths.has(path)) {
|
||||
return errorResponse('Not implemented', 501);
|
||||
}
|
||||
}
|
||||
const authenticatedResponse = await handleAuthenticatedRoute(request, env, userId, currentUser, path, method);
|
||||
if (authenticatedResponse) return authenticatedResponse;
|
||||
|
||||
// Account endpoints
|
||||
if (path === '/api/accounts/profile') {
|
||||
if (method === 'GET') return handleGetProfile(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if ((path === '/api/accounts/password' || path === '/api/accounts/change-password') && (method === 'POST' || method === 'PUT')) {
|
||||
return handleChangePassword(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/keys' && method === 'POST') {
|
||||
return handleSetKeys(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/totp') {
|
||||
if (method === 'GET') return handleGetTotpStatus(request, env, userId);
|
||||
if (method === 'PUT' || method === 'POST') return handleSetTotpStatus(request, env, userId);
|
||||
}
|
||||
|
||||
if ((path === '/api/accounts/totp/recovery-code' || path === '/api/two-factor/get-recover') && method === 'POST') {
|
||||
return handleGetTotpRecoveryCode(request, env, userId);
|
||||
}
|
||||
|
||||
// Revision date endpoint
|
||||
if (path === '/api/accounts/revision-date' && method === 'GET') {
|
||||
return handleGetRevisionDate(request, env, userId);
|
||||
}
|
||||
|
||||
// Verify password endpoint
|
||||
if (path === '/api/accounts/verify-password' && method === 'POST') {
|
||||
return handleVerifyPassword(request, env, userId);
|
||||
}
|
||||
|
||||
// Sync endpoint
|
||||
if (path === '/api/sync' && method === 'GET') {
|
||||
return handleSync(request, env, userId);
|
||||
}
|
||||
|
||||
if (path.startsWith('/notifications/')) {
|
||||
return errorResponse('Not found', 404);
|
||||
}
|
||||
|
||||
// Cipher endpoints
|
||||
if (path === '/api/ciphers' || path === '/api/ciphers/create') {
|
||||
if (method === 'GET') return handleGetCiphers(request, env, userId);
|
||||
if (method === 'POST') return handleCreateCipher(request, env, userId);
|
||||
}
|
||||
|
||||
// Ciphers import endpoint (Bitwarden client format)
|
||||
if (path === '/api/ciphers/import' && method === 'POST') {
|
||||
return handleCiphersImport(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers/delete' && method === 'POST') {
|
||||
return handleBulkDeleteCiphers(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers/delete-permanent' && method === 'POST') {
|
||||
return handleBulkPermanentDeleteCiphers(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers/restore' && method === 'POST') {
|
||||
return handleBulkRestoreCiphers(request, env, userId);
|
||||
}
|
||||
|
||||
// Bulk cipher operations (only move is allowed)
|
||||
if (path === '/api/ciphers/move') {
|
||||
if (method === 'POST' || method === 'PUT') {
|
||||
return handleBulkMoveCiphers(request, env, userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Match /api/ciphers/:id patterns
|
||||
const cipherMatch = path.match(/^\/api\/ciphers\/([a-f0-9-]+)(\/.*)?$/i);
|
||||
if (cipherMatch) {
|
||||
const cipherId = cipherMatch[1];
|
||||
const subPath = cipherMatch[2] || '';
|
||||
|
||||
if (subPath === '' || subPath === '/') {
|
||||
if (method === 'GET') return handleGetCipher(request, env, userId, cipherId);
|
||||
if (method === 'PUT' || method === 'POST') return handleUpdateCipher(request, env, userId, cipherId);
|
||||
if (method === 'DELETE') return handleDeleteCipherCompat(request, env, userId, cipherId);
|
||||
}
|
||||
|
||||
if (subPath === '/delete' && method === 'PUT') {
|
||||
return handleDeleteCipher(request, env, userId, cipherId);
|
||||
}
|
||||
|
||||
if (subPath === '/delete' && method === 'DELETE') {
|
||||
return handlePermanentDeleteCipher(request, env, userId, cipherId);
|
||||
}
|
||||
|
||||
if (subPath === '/restore' && method === 'PUT') {
|
||||
return handleRestoreCipher(request, env, userId, cipherId);
|
||||
}
|
||||
|
||||
if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) {
|
||||
return handlePartialUpdateCipher(request, env, userId, cipherId);
|
||||
}
|
||||
|
||||
// Share endpoint - just return the cipher (single user mode)
|
||||
if (subPath === '/share' && method === 'POST') {
|
||||
return handleGetCipher(request, env, userId, cipherId);
|
||||
}
|
||||
|
||||
if (subPath === '/details' && method === 'GET') {
|
||||
return handleGetCipher(request, env, userId, cipherId);
|
||||
}
|
||||
|
||||
// Attachment endpoints
|
||||
// POST /api/ciphers/{id}/attachment/v2 - Create attachment metadata
|
||||
if (subPath === '/attachment/v2' && method === 'POST') {
|
||||
return handleCreateAttachment(request, env, userId, cipherId);
|
||||
}
|
||||
|
||||
// Legacy attachment endpoint - also goes to v2 flow
|
||||
if (subPath === '/attachment' && method === 'POST') {
|
||||
return handleCreateAttachment(request, env, userId, cipherId);
|
||||
}
|
||||
|
||||
// Match /api/ciphers/{id}/attachment/{attachmentId}
|
||||
const attachmentMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)$/i);
|
||||
if (attachmentMatch) {
|
||||
const attachmentId = attachmentMatch[1];
|
||||
if (method === 'POST') return handleUploadAttachment(request, env, userId, cipherId, attachmentId);
|
||||
if (method === 'GET') return handleGetAttachment(request, env, userId, cipherId, attachmentId);
|
||||
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
|
||||
}
|
||||
|
||||
// DELETE via POST (legacy)
|
||||
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
|
||||
if (attachmentDeleteMatch && method === 'POST') {
|
||||
const attachmentId = attachmentDeleteMatch[1];
|
||||
return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Folder endpoints
|
||||
if (path === '/api/folders') {
|
||||
if (method === 'GET') return handleGetFolders(request, env, userId);
|
||||
if (method === 'POST') return handleCreateFolder(request, env, userId);
|
||||
}
|
||||
if (path === '/api/folders/delete' && method === 'POST') {
|
||||
return handleBulkDeleteFolders(request, env, userId);
|
||||
}
|
||||
|
||||
// Match /api/folders/:id patterns
|
||||
const folderMatch = path.match(/^\/api\/folders\/([a-f0-9-]+)$/i);
|
||||
if (folderMatch) {
|
||||
const folderId = folderMatch[1];
|
||||
if (method === 'GET') return handleGetFolder(request, env, userId, folderId);
|
||||
if (method === 'PUT') return handleUpdateFolder(request, env, userId, folderId);
|
||||
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
|
||||
}
|
||||
|
||||
// Auth requests endpoint (stub - we don't support passwordless login)
|
||||
if (path.startsWith('/api/auth-requests')) {
|
||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||
}
|
||||
|
||||
// Collections endpoint (stub - no organization support)
|
||||
if (path === '/api/collections' || path.startsWith('/api/collections/')) {
|
||||
if (method === 'GET') {
|
||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||
}
|
||||
}
|
||||
|
||||
// Organizations endpoint (stub - no organization support)
|
||||
if (path === '/api/organizations' || path.startsWith('/api/organizations/')) {
|
||||
if (method === 'GET') {
|
||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||
}
|
||||
}
|
||||
|
||||
// Send endpoints
|
||||
if (path === '/api/sends') {
|
||||
if (method === 'GET') return handleGetSends(request, env, userId);
|
||||
if (method === 'POST') return handleCreateSend(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/sends/delete' && method === 'POST') {
|
||||
return handleBulkDeleteSends(request, env, userId);
|
||||
}
|
||||
|
||||
if ((path === '/api/sends/file/v2' || path === '/api/sends/file') && method === 'POST') {
|
||||
return handleCreateFileSendV2(request, env, userId);
|
||||
}
|
||||
|
||||
const sendMatch = path.match(/^\/api\/sends\/([^/]+)(\/.*)?$/i);
|
||||
if (sendMatch) {
|
||||
const sendId = sendMatch[1];
|
||||
const subPath = sendMatch[2] || '';
|
||||
|
||||
if (subPath === '' || subPath === '/') {
|
||||
if (method === 'GET') return handleGetSend(request, env, userId, sendId);
|
||||
if (method === 'PUT') return handleUpdateSend(request, env, userId, sendId);
|
||||
if (method === 'DELETE') return handleDeleteSend(request, env, userId, sendId);
|
||||
}
|
||||
|
||||
if (subPath === '/remove-password' && (method === 'PUT' || method === 'POST')) {
|
||||
return handleRemoveSendPassword(request, env, userId, sendId);
|
||||
}
|
||||
|
||||
if (subPath === '/remove-auth' && (method === 'PUT' || method === 'POST')) {
|
||||
return handleRemoveSendAuth(request, env, userId, sendId);
|
||||
}
|
||||
|
||||
const sendFileUploadMatch = subPath.match(/^\/file\/([^/]+)\/?$/i);
|
||||
if (sendFileUploadMatch) {
|
||||
const fileId = sendFileUploadMatch[1];
|
||||
if (method === 'GET') return handleGetSendFileUpload(request, env, userId, sendId, fileId);
|
||||
if (method === 'POST' || method === 'PUT') return handleUploadSendFile(request, env, userId, sendId, fileId);
|
||||
}
|
||||
}
|
||||
|
||||
// Policies endpoint (stub - not implemented)
|
||||
if (path === '/api/policies' || path.startsWith('/api/policies/')) {
|
||||
if (method === 'GET') {
|
||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||
}
|
||||
}
|
||||
|
||||
// Settings domains endpoint (stub)
|
||||
if (path === '/api/settings/domains') {
|
||||
if (method === 'GET') {
|
||||
return jsonResponse({
|
||||
equivalentDomains: [],
|
||||
globalEquivalentDomains: [],
|
||||
object: 'domains',
|
||||
});
|
||||
}
|
||||
if (method === 'PUT' || method === 'POST') {
|
||||
return jsonResponse({
|
||||
equivalentDomains: [],
|
||||
globalEquivalentDomains: [],
|
||||
object: 'domains',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const authenticatedDeviceResponse = await handleAuthenticatedDeviceRoute(request, env, userId, path, method);
|
||||
if (authenticatedDeviceResponse) return authenticatedDeviceResponse;
|
||||
|
||||
const adminResponse = await handleAdminRoute(request, env, currentUser, path, method);
|
||||
if (adminResponse) return adminResponse;
|
||||
|
||||
// Not found
|
||||
return errorResponse('Not found', 404);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Request error:', error);
|
||||
return errorResponse('Internal server error', 500);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { AuditLog, Invite } from '../types';
|
||||
|
||||
export async function createInvite(db: D1Database, invite: Invite): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO invites(code, created_by, used_by, expires_at, status, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.bind(invite.code, invite.createdBy, invite.usedBy, invite.expiresAt, invite.status, invite.createdAt, invite.updatedAt)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function getInvite(db: D1Database, code: string): Promise<Invite | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites WHERE code = ?')
|
||||
.bind(code)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
return {
|
||||
code: row.code,
|
||||
createdBy: row.created_by,
|
||||
usedBy: row.used_by ?? null,
|
||||
expiresAt: row.expires_at,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listInvites(db: D1Database, includeInactive: boolean = false): Promise<Invite[]> {
|
||||
const now = new Date().toISOString();
|
||||
const predicate = includeInactive
|
||||
? '1 = 1'
|
||||
: "(status = 'active' AND expires_at > ?)";
|
||||
const query =
|
||||
'SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites ' +
|
||||
`WHERE ${predicate} ORDER BY created_at DESC`;
|
||||
const res = includeInactive
|
||||
? await db.prepare(query).all<any>()
|
||||
: await db.prepare(query).bind(now).all<any>();
|
||||
|
||||
return (res.results || []).map((row) => ({
|
||||
code: row.code,
|
||||
createdBy: row.created_by,
|
||||
usedBy: row.used_by ?? null,
|
||||
expiresAt: row.expires_at,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare(
|
||||
"UPDATE invites SET status = 'used', used_by = ?, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?"
|
||||
)
|
||||
.bind(userId, now, code, now)
|
||||
.run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function revokeInvite(db: D1Database, code: string): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare("UPDATE invites SET status = 'revoked', updated_at = ? WHERE code = ? AND status = 'active'")
|
||||
.bind(now, code)
|
||||
.run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function deleteAllInvites(db: D1Database): Promise<number> {
|
||||
const result = await db.prepare('DELETE FROM invites').run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function createAuditLog(db: D1Database, log: AuditLog): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO audit_logs(id, actor_user_id, action, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.bind(log.id, log.actorUserId, log.action, log.targetType, log.targetId, log.metadata, log.createdAt)
|
||||
.run();
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Attachment, Cipher } from '../types';
|
||||
|
||||
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||
type SqlChunkSize = (fixedBindCount: number) => number;
|
||||
type GetCipher = (id: string) => Promise<Cipher | null>;
|
||||
type SaveCipher = (cipher: Cipher) => Promise<void>;
|
||||
type UpdateRevisionDate = (userId: string) => Promise<string>;
|
||||
|
||||
export async function getAttachment(db: D1Database, id: string): Promise<Attachment | null> {
|
||||
const row = await db
|
||||
.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,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveAttachment(db: D1Database, safeBind: SafeBind, attachment: Attachment): Promise<void> {
|
||||
const stmt = 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'
|
||||
);
|
||||
await safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run();
|
||||
}
|
||||
|
||||
export async function deleteAttachment(db: D1Database, id: string): Promise<void> {
|
||||
await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
|
||||
}
|
||||
|
||||
export async function getAttachmentsByCipher(db: D1Database, cipherId: string): Promise<Attachment[]> {
|
||||
const res = await db
|
||||
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?')
|
||||
.bind(cipherId)
|
||||
.all<any>();
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getAttachmentsByCipherIds(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
cipherIds: string[]
|
||||
): Promise<Map<string, Attachment[]>> {
|
||||
const grouped = new Map<string, Attachment[]>();
|
||||
if (cipherIds.length === 0) return grouped;
|
||||
|
||||
const uniqueCipherIds = [...new Set(cipherIds)];
|
||||
const chunkSize = sqlChunkSize(0);
|
||||
|
||||
for (let i = 0; i < uniqueCipherIds.length; i += chunkSize) {
|
||||
const chunk = uniqueCipherIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
const res = await db
|
||||
.prepare(`SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id IN (${placeholders})`)
|
||||
.bind(...chunk)
|
||||
.all<any>();
|
||||
|
||||
for (const row of res.results || []) {
|
||||
const item: Attachment = {
|
||||
id: row.id,
|
||||
cipherId: row.cipher_id,
|
||||
fileName: row.file_name,
|
||||
size: row.size,
|
||||
sizeName: row.size_name,
|
||||
key: row.key,
|
||||
};
|
||||
const list = grouped.get(item.cipherId);
|
||||
if (list) list.push(item);
|
||||
else grouped.set(item.cipherId, [item]);
|
||||
}
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export async function getAttachmentsByUserId(db: D1Database, userId: string): Promise<Map<string, Attachment[]>> {
|
||||
const grouped = new Map<string, Attachment[]>();
|
||||
const res = await db
|
||||
.prepare(
|
||||
`SELECT a.id, a.cipher_id, a.file_name, a.size, a.size_name, a.key
|
||||
FROM attachments a
|
||||
INNER JOIN ciphers c ON c.id = a.cipher_id
|
||||
WHERE c.user_id = ?`
|
||||
)
|
||||
.bind(userId)
|
||||
.all<any>();
|
||||
|
||||
for (const row of res.results || []) {
|
||||
const item: Attachment = {
|
||||
id: row.id,
|
||||
cipherId: row.cipher_id,
|
||||
fileName: row.file_name,
|
||||
size: row.size,
|
||||
sizeName: row.size_name,
|
||||
key: row.key,
|
||||
};
|
||||
const list = grouped.get(item.cipherId);
|
||||
if (list) list.push(item);
|
||||
else grouped.set(item.cipherId, [item]);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export async function addAttachmentToCipher(db: D1Database, cipherId: string, attachmentId: string): Promise<void> {
|
||||
await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run();
|
||||
}
|
||||
|
||||
export async function removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
|
||||
void cipherId;
|
||||
void attachmentId;
|
||||
}
|
||||
|
||||
export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> {
|
||||
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
|
||||
}
|
||||
|
||||
export async function updateCipherRevisionDate(
|
||||
getCipherById: GetCipher,
|
||||
saveCipherRecord: SaveCipher,
|
||||
updateRevisionDate: UpdateRevisionDate,
|
||||
cipherId: string
|
||||
): Promise<{ userId: string; revisionDate: string } | null> {
|
||||
const cipher = await getCipherById(cipherId);
|
||||
if (!cipher) return null;
|
||||
cipher.updatedAt = new Date().toISOString();
|
||||
await saveCipherRecord(cipher);
|
||||
const revisionDate = await updateRevisionDate(cipher.userId);
|
||||
return { userId: cipher.userId, revisionDate };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
type ShouldRunPeriodicCleanup = (lastRunAt: number, intervalMs: number) => boolean;
|
||||
|
||||
export async function ensureUsedAttachmentDownloadTokenTable(db: D1Database): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
|
||||
'jti TEXT PRIMARY KEY, ' +
|
||||
'expires_at INTEGER NOT NULL' +
|
||||
')'
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function consumeAttachmentDownloadToken(
|
||||
db: D1Database,
|
||||
shouldRunPeriodicCleanup: ShouldRunPeriodicCleanup,
|
||||
lastCleanupAt: number,
|
||||
cleanupIntervalMs: number,
|
||||
jti: string,
|
||||
expUnixSeconds: number
|
||||
): Promise<{ consumed: boolean; cleanedUpAt: number | null }> {
|
||||
const nowMs = Date.now();
|
||||
let cleanedUpAt: number | null = null;
|
||||
|
||||
if (shouldRunPeriodicCleanup(lastCleanupAt, cleanupIntervalMs)) {
|
||||
await db
|
||||
.prepare('DELETE FROM used_attachment_download_tokens WHERE expires_at < ?')
|
||||
.bind(nowMs)
|
||||
.run();
|
||||
cleanedUpAt = nowMs;
|
||||
}
|
||||
|
||||
const expiresAtMs = expUnixSeconds * 1000;
|
||||
const result = await db
|
||||
.prepare(
|
||||
'INSERT INTO used_attachment_download_tokens(jti, expires_at) VALUES(?, ?) ' +
|
||||
'ON CONFLICT(jti) DO NOTHING'
|
||||
)
|
||||
.bind(jti, expiresAtMs)
|
||||
.run();
|
||||
|
||||
return {
|
||||
consumed: (result.meta.changes ?? 0) > 0,
|
||||
cleanedUpAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { Cipher } from '../types';
|
||||
|
||||
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||
type SqlChunkSize = (fixedBindCount: number) => number;
|
||||
type UpdateRevisionDate = (userId: string) => Promise<string>;
|
||||
|
||||
export async function getCipher(db: D1Database, id: string): Promise<Cipher | null> {
|
||||
const row = await db.prepare('SELECT data FROM ciphers WHERE id = ?').bind(id).first<{ data: string }>();
|
||||
if (!row?.data) return null;
|
||||
try {
|
||||
return JSON.parse(row.data) as Cipher;
|
||||
} catch {
|
||||
console.error('Corrupted cipher data, id:', id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
|
||||
const data = JSON.stringify(cipher);
|
||||
const stmt = 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'
|
||||
);
|
||||
await safeBind(
|
||||
stmt,
|
||||
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();
|
||||
}
|
||||
|
||||
export async function deleteCipher(db: D1Database, id: string, userId: string): Promise<void> {
|
||||
await db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
||||
}
|
||||
|
||||
export async function bulkSoftDeleteCiphers(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
updateRevisionDate: UpdateRevisionDate,
|
||||
ids: string[],
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
if (ids.length === 0) return null;
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const patch = JSON.stringify({ deletedAt: now, updatedAt: now });
|
||||
const chunkSize = sqlChunkSize(4);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET deleted_at = ?, updated_at = ?, data = json_patch(data, ?)
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(now, now, patch, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
return updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
export async function bulkRestoreCiphers(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
updateRevisionDate: UpdateRevisionDate,
|
||||
ids: string[],
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
if (ids.length === 0) return null;
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const patch = JSON.stringify({ deletedAt: null, updatedAt: now });
|
||||
const chunkSize = sqlChunkSize(3);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET deleted_at = NULL, updated_at = ?, data = json_patch(data, ?)
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(now, patch, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
return updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
export async function bulkDeleteCiphers(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
updateRevisionDate: UpdateRevisionDate,
|
||||
ids: string[],
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
if (ids.length === 0) return null;
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const chunkSize = sqlChunkSize(1);
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
await db.prepare(`DELETE FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`).bind(userId, ...chunk).run();
|
||||
}
|
||||
|
||||
return updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
export async function getAllCiphers(db: D1Database, userId: string): Promise<Cipher[]> {
|
||||
const res = await db.prepare('SELECT data FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC').bind(userId).all<{ data: string }>();
|
||||
return (res.results || []).flatMap((r) => {
|
||||
try {
|
||||
return [JSON.parse(r.data) as Cipher];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCiphersPage(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
includeDeleted: boolean,
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<Cipher[]> {
|
||||
const whereDeleted = includeDeleted ? '' : 'AND deleted_at IS NULL';
|
||||
const res = await db
|
||||
.prepare(
|
||||
`SELECT data FROM ciphers
|
||||
WHERE user_id = ?
|
||||
${whereDeleted}
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
)
|
||||
.bind(userId, limit, offset)
|
||||
.all<{ data: string }>();
|
||||
return (res.results || []).flatMap((r) => {
|
||||
try {
|
||||
return [JSON.parse(r.data) as Cipher];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCiphersByIds(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
ids: string[],
|
||||
userId: string
|
||||
): Promise<Cipher[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
if (!uniqueIds.length) return [];
|
||||
|
||||
const chunkSize = sqlChunkSize(1);
|
||||
const out: Cipher[] = [];
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
const stmt = db.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`);
|
||||
const res = await stmt.bind(userId, ...chunk).all<{ data: string }>();
|
||||
out.push(
|
||||
...(res.results || []).flatMap((r) => {
|
||||
try {
|
||||
return [JSON.parse(r.data) as Cipher];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function bulkMoveCiphers(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
updateRevisionDate: UpdateRevisionDate,
|
||||
ids: string[],
|
||||
folderId: string | null,
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
if (ids.length === 0) return null;
|
||||
const now = new Date().toISOString();
|
||||
const uniqueIds = Array.from(new Set(ids));
|
||||
const patch = JSON.stringify({ folderId, updatedAt: now });
|
||||
const chunkSize = sqlChunkSize(4);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET folder_id = ?, updated_at = ?, data = json_patch(data, ?)
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(folderId, now, patch, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
return updateRevisionDate(userId);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export async function isRegistered(db: D1Database): Promise<boolean> {
|
||||
const row = await db.prepare('SELECT value FROM config WHERE key = ?').bind('registered').first<{ value: string }>();
|
||||
return row?.value === 'true';
|
||||
}
|
||||
|
||||
export async function getConfigValue(db: D1Database, key: string): Promise<string | null> {
|
||||
const row = await db.prepare('SELECT value FROM config WHERE key = ?').bind(key).first<{ value: string }>();
|
||||
return typeof row?.value === 'string' ? row.value : null;
|
||||
}
|
||||
|
||||
export async function setConfigValue(db: D1Database, key: string, value: string): Promise<void> {
|
||||
await db
|
||||
.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||
.bind(key, value)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function setRegistered(db: D1Database): Promise<void> {
|
||||
await db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||
.bind('registered', 'true')
|
||||
.run();
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { Device, TrustedDeviceTokenSummary, User } from '../types';
|
||||
|
||||
type GetUserByEmail = (email: string) => Promise<User | null>;
|
||||
type TrustedTokenKeyFn = (token: string) => Promise<string>;
|
||||
|
||||
function mapDeviceRow(row: any): Device {
|
||||
return {
|
||||
userId: row.user_id,
|
||||
deviceIdentifier: row.device_identifier,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
sessionStamp: row.session_stamp || '',
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertDevice(
|
||||
db: D1Database,
|
||||
getDeviceById: (userId: string, deviceIdentifier: string) => Promise<Device | null>,
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
name: string,
|
||||
type: number,
|
||||
sessionStamp?: string
|
||||
): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || '';
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, 0, NULL, ?, ?) ' +
|
||||
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, updated_at=excluded.updated_at'
|
||||
)
|
||||
.bind(userId, deviceIdentifier, name, type, effectiveSessionStamp, now, now)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function isKnownDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||
const row = await db
|
||||
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||
.bind(userId, deviceIdentifier)
|
||||
.first<{ '1': number }>();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export async function isKnownDeviceByEmail(
|
||||
getUserByEmail: GetUserByEmail,
|
||||
isKnownDeviceForUser: (userId: string, deviceIdentifier: string) => Promise<boolean>,
|
||||
email: string,
|
||||
deviceIdentifier: string
|
||||
): Promise<boolean> {
|
||||
const user = await getUserByEmail(email);
|
||||
if (!user) return false;
|
||||
return isKnownDeviceForUser(user.id, deviceIdentifier);
|
||||
}
|
||||
|
||||
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
|
||||
const res = await db
|
||||
.prepare(
|
||||
'SELECT user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at ' +
|
||||
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC'
|
||||
)
|
||||
.bind(userId)
|
||||
.all<any>();
|
||||
return (res.results || []).map(mapDeviceRow);
|
||||
}
|
||||
|
||||
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at ' +
|
||||
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
|
||||
)
|
||||
.bind(userId, deviceIdentifier)
|
||||
.first<any>();
|
||||
return row ? mapDeviceRow(row) : null;
|
||||
}
|
||||
|
||||
export async function deleteDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||
const result = await db
|
||||
.prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?')
|
||||
.bind(userId, deviceIdentifier)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function deleteDevicesByUserId(db: D1Database, userId: string): Promise<number> {
|
||||
const result = await db.prepare('DELETE FROM devices WHERE user_id = ?').bind(userId).run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function getTrustedDeviceTokenSummariesByUserId(db: D1Database, userId: string): Promise<TrustedDeviceTokenSummary[]> {
|
||||
const now = Date.now();
|
||||
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run();
|
||||
|
||||
const res = await db
|
||||
.prepare(
|
||||
'SELECT device_identifier, MAX(expires_at) AS expires_at, COUNT(*) AS token_count ' +
|
||||
'FROM trusted_two_factor_device_tokens WHERE user_id = ? GROUP BY device_identifier ORDER BY expires_at DESC'
|
||||
)
|
||||
.bind(userId)
|
||||
.all<any>();
|
||||
|
||||
return (res.results || []).map((row) => ({
|
||||
deviceIdentifier: row.device_identifier,
|
||||
expiresAt: Number(row.expires_at || 0),
|
||||
tokenCount: Number(row.token_count || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function deleteTrustedTwoFactorTokensByDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<number> {
|
||||
const result = await db
|
||||
.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE user_id = ? AND device_identifier = ?')
|
||||
.bind(userId, deviceIdentifier)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function deleteTrustedTwoFactorTokensByUserId(db: D1Database, userId: string): Promise<number> {
|
||||
const result = await db
|
||||
.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE user_id = ?')
|
||||
.bind(userId)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function saveTrustedTwoFactorDeviceToken(
|
||||
db: D1Database,
|
||||
trustedTokenKey: TrustedTokenKeyFn,
|
||||
token: string,
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
expiresAtMs: number
|
||||
): Promise<void> {
|
||||
const tokenKey = await trustedTokenKey(token);
|
||||
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(Date.now()).run();
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO trusted_two_factor_device_tokens(token, user_id, device_identifier, expires_at) VALUES(?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, device_identifier=excluded.device_identifier, expires_at=excluded.expires_at'
|
||||
)
|
||||
.bind(tokenKey, userId, deviceIdentifier, expiresAtMs)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function getTrustedTwoFactorDeviceTokenUserId(
|
||||
db: D1Database,
|
||||
trustedTokenKey: TrustedTokenKeyFn,
|
||||
token: string,
|
||||
deviceIdentifier: string
|
||||
): Promise<string | null> {
|
||||
const now = Date.now();
|
||||
const tokenKey = await trustedTokenKey(token);
|
||||
const row = await db
|
||||
.prepare('SELECT user_id, expires_at FROM trusted_two_factor_device_tokens WHERE token = ? AND device_identifier = ?')
|
||||
.bind(tokenKey, deviceIdentifier)
|
||||
.first<{ user_id: string; expires_at: number }>();
|
||||
|
||||
if (!row) return null;
|
||||
if (row.expires_at && row.expires_at < now) {
|
||||
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE token = ?').bind(tokenKey).run();
|
||||
return null;
|
||||
}
|
||||
return row.user_id;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { Cipher, Folder } from '../types';
|
||||
|
||||
function mapFolderRow(row: any): Folder {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
name: row.name,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFolder(db: D1Database, id: string): Promise<Folder | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE id = ?')
|
||||
.bind(id)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
return mapFolderRow(row);
|
||||
}
|
||||
|
||||
export async function saveFolder(db: D1Database, folder: Folder): Promise<void> {
|
||||
await 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();
|
||||
}
|
||||
|
||||
export async function deleteFolder(db: D1Database, id: string, userId: string): Promise<void> {
|
||||
await db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
||||
}
|
||||
|
||||
export async function clearFolderFromCiphers(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
folderId: string,
|
||||
saveCipher: (cipher: Cipher) => Promise<void>
|
||||
): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
const res = await db
|
||||
.prepare('SELECT data FROM ciphers WHERE user_id = ? AND folder_id = ?')
|
||||
.bind(userId, folderId)
|
||||
.all<{ data: string }>();
|
||||
|
||||
for (const row of (res.results || [])) {
|
||||
let cipher: Cipher;
|
||||
try {
|
||||
cipher = JSON.parse(row.data) as Cipher;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
cipher.folderId = null;
|
||||
cipher.updatedAt = now;
|
||||
await saveCipher(cipher);
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkDeleteFolders(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
ids: string[],
|
||||
sqlChunkSize: (fixedBindCount: number) => number,
|
||||
saveCipher: (cipher: Cipher) => Promise<void>,
|
||||
updateRevisionDate: (userId: string) => Promise<string>
|
||||
): Promise<string | null> {
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const chunkSize = sqlChunkSize(1);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
const res = await db
|
||||
.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND folder_id IN (${placeholders})`)
|
||||
.bind(userId, ...chunk)
|
||||
.all<{ data: string }>();
|
||||
|
||||
for (const row of res.results || []) {
|
||||
let cipher: Cipher;
|
||||
try {
|
||||
cipher = JSON.parse(row.data) as Cipher;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
cipher.folderId = null;
|
||||
cipher.updatedAt = now;
|
||||
await saveCipher(cipher);
|
||||
}
|
||||
|
||||
await db
|
||||
.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
|
||||
.bind(userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
return updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
export async function getAllFolders(db: D1Database, userId: string): Promise<Folder[]> {
|
||||
const res = await 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((row) => mapFolderRow(row));
|
||||
}
|
||||
|
||||
export async function getFoldersPage(db: D1Database, userId: string, limit: number, offset: number): Promise<Folder[]> {
|
||||
const res = await db
|
||||
.prepare(
|
||||
'SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?'
|
||||
)
|
||||
.bind(userId, limit, offset)
|
||||
.all<any>();
|
||||
return (res.results || []).map((row) => mapFolderRow(row));
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { RefreshTokenRecord } from '../types';
|
||||
|
||||
type RefreshTokenKeyFn = (token: string) => Promise<string>;
|
||||
type CleanupExpiredFn = (nowMs: number) => Promise<void>;
|
||||
|
||||
export async function saveRefreshToken(
|
||||
db: D1Database,
|
||||
refreshTokenKey: RefreshTokenKeyFn,
|
||||
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
|
||||
token: string,
|
||||
userId: string,
|
||||
expiresAtMs: number,
|
||||
deviceIdentifier?: string | null,
|
||||
deviceSessionStamp?: string | null
|
||||
): Promise<void> {
|
||||
await maybeCleanupExpiredRefreshTokens(Date.now());
|
||||
const tokenKey = await refreshTokenKey(token);
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp) VALUES(?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, device_session_stamp=excluded.device_session_stamp'
|
||||
)
|
||||
.bind(tokenKey, userId, expiresAtMs, deviceIdentifier ?? null, deviceSessionStamp ?? null)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function getRefreshTokenRecord(
|
||||
db: D1Database,
|
||||
refreshTokenKey: RefreshTokenKeyFn,
|
||||
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
|
||||
saveRefreshTokenRecord: (
|
||||
token: string,
|
||||
userId: string,
|
||||
expiresAtMs?: number,
|
||||
deviceIdentifier?: string | null,
|
||||
deviceSessionStamp?: string | null
|
||||
) => Promise<void>,
|
||||
deleteRefreshTokenRecord: (token: string) => Promise<void>,
|
||||
token: string
|
||||
): Promise<RefreshTokenRecord | null> {
|
||||
const now = Date.now();
|
||||
await maybeCleanupExpiredRefreshTokens(now);
|
||||
const tokenKey = await refreshTokenKey(token);
|
||||
|
||||
let row = await db
|
||||
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
|
||||
.bind(tokenKey)
|
||||
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
|
||||
|
||||
if (!row) {
|
||||
const legacyRow = await db
|
||||
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
|
||||
.bind(token)
|
||||
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
|
||||
|
||||
if (legacyRow) {
|
||||
if (legacyRow.expires_at && legacyRow.expires_at < now) {
|
||||
await deleteRefreshTokenRecord(token);
|
||||
return null;
|
||||
}
|
||||
await saveRefreshTokenRecord(
|
||||
token,
|
||||
legacyRow.user_id,
|
||||
legacyRow.expires_at,
|
||||
legacyRow.device_identifier ?? null,
|
||||
legacyRow.device_session_stamp ?? null
|
||||
);
|
||||
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
|
||||
return {
|
||||
userId: legacyRow.user_id,
|
||||
expiresAt: legacyRow.expires_at,
|
||||
deviceIdentifier: legacyRow.device_identifier ?? null,
|
||||
deviceSessionStamp: legacyRow.device_session_stamp ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!row) return null;
|
||||
if (row.expires_at && row.expires_at < now) {
|
||||
await deleteRefreshTokenRecord(token);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
userId: row.user_id,
|
||||
expiresAt: row.expires_at,
|
||||
deviceIdentifier: row.device_identifier ?? null,
|
||||
deviceSessionStamp: row.device_session_stamp ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteRefreshToken(db: D1Database, refreshTokenKey: RefreshTokenKeyFn, token: string): Promise<void> {
|
||||
const tokenKey = await refreshTokenKey(token);
|
||||
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
|
||||
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run();
|
||||
}
|
||||
|
||||
export async function deleteRefreshTokensByUserId(db: D1Database, userId: string): Promise<number> {
|
||||
const result = await db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').bind(userId).run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function deleteRefreshTokensByDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<number> {
|
||||
const result = await db
|
||||
.prepare('DELETE FROM refresh_tokens WHERE user_id = ? AND device_identifier = ?')
|
||||
.bind(userId, deviceIdentifier)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function constrainRefreshTokenExpiry(
|
||||
db: D1Database,
|
||||
refreshTokenKey: RefreshTokenKeyFn,
|
||||
token: string,
|
||||
maxExpiresAtMs: number
|
||||
): Promise<void> {
|
||||
const tokenKey = await refreshTokenKey(token);
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
'UPDATE refresh_tokens ' +
|
||||
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
|
||||
'WHERE token = ?'
|
||||
)
|
||||
.bind(maxExpiresAtMs, maxExpiresAtMs, tokenKey)
|
||||
.run();
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
'UPDATE refresh_tokens ' +
|
||||
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
|
||||
'WHERE token = ?'
|
||||
)
|
||||
.bind(maxExpiresAtMs, maxExpiresAtMs, token)
|
||||
.run();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export async function getRevisionDate(db: D1Database, userId: string): Promise<string> {
|
||||
const row = await db
|
||||
.prepare('SELECT revision_date FROM user_revisions WHERE user_id = ?')
|
||||
.bind(userId)
|
||||
.first<{ revision_date: string }>();
|
||||
|
||||
if (row?.revision_date) return row.revision_date;
|
||||
|
||||
const date = new Date().toISOString();
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' +
|
||||
'ON CONFLICT(user_id) DO NOTHING'
|
||||
)
|
||||
.bind(userId, date)
|
||||
.run();
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
export async function updateRevisionDate(db: D1Database, userId: string): Promise<string> {
|
||||
const date = new Date().toISOString();
|
||||
await 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;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { Send } from '../types';
|
||||
|
||||
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||
type SqlChunkSize = (fixedBindCount: number) => number;
|
||||
type UpdateRevisionDate = (userId: string) => Promise<string>;
|
||||
|
||||
function mapSendRow(row: any): Send {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
type: row.type,
|
||||
name: row.name,
|
||||
notes: row.notes,
|
||||
data: row.data,
|
||||
key: row.key,
|
||||
passwordHash: row.password_hash,
|
||||
passwordSalt: row.password_salt,
|
||||
passwordIterations: row.password_iterations,
|
||||
authType: row.auth_type ?? 0,
|
||||
emails: row.emails ?? null,
|
||||
maxAccessCount: row.max_access_count,
|
||||
accessCount: row.access_count,
|
||||
disabled: !!row.disabled,
|
||||
hideEmail: row.hide_email === null || row.hide_email === undefined ? null : !!row.hide_email,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
expirationDate: row.expiration_date,
|
||||
deletionDate: row.deletion_date,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSend(db: D1Database, id: string): Promise<Send | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE id = ?'
|
||||
)
|
||||
.bind(id)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
return mapSendRow(row);
|
||||
}
|
||||
|
||||
export async function saveSend(db: D1Database, safeBind: SafeBind, send: Send): Promise<void> {
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO sends(id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'user_id=excluded.user_id, type=excluded.type, name=excluded.name, notes=excluded.notes, data=excluded.data, key=excluded.key, ' +
|
||||
'password_hash=excluded.password_hash, password_salt=excluded.password_salt, password_iterations=excluded.password_iterations, auth_type=excluded.auth_type, emails=excluded.emails, ' +
|
||||
'max_access_count=excluded.max_access_count, access_count=excluded.access_count, disabled=excluded.disabled, hide_email=excluded.hide_email, ' +
|
||||
'updated_at=excluded.updated_at, expiration_date=excluded.expiration_date, deletion_date=excluded.deletion_date'
|
||||
);
|
||||
|
||||
await safeBind(
|
||||
stmt,
|
||||
send.id,
|
||||
send.userId,
|
||||
Number(send.type) || 0,
|
||||
send.name,
|
||||
send.notes,
|
||||
send.data,
|
||||
send.key,
|
||||
send.passwordHash,
|
||||
send.passwordSalt,
|
||||
send.passwordIterations,
|
||||
send.authType,
|
||||
send.emails,
|
||||
send.maxAccessCount,
|
||||
send.accessCount,
|
||||
send.disabled ? 1 : 0,
|
||||
send.hideEmail === null || send.hideEmail === undefined ? null : send.hideEmail ? 1 : 0,
|
||||
send.createdAt,
|
||||
send.updatedAt,
|
||||
send.expirationDate,
|
||||
send.deletionDate
|
||||
).run();
|
||||
}
|
||||
|
||||
export async function incrementSendAccessCount(db: D1Database, sendId: string): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare(
|
||||
'UPDATE sends SET access_count = access_count + 1, updated_at = ? ' +
|
||||
'WHERE id = ? AND (max_access_count IS NULL OR access_count < max_access_count)'
|
||||
)
|
||||
.bind(now, sendId)
|
||||
.run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function deleteSend(db: D1Database, id: string, userId: string): Promise<void> {
|
||||
await db.prepare('DELETE FROM sends WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
||||
}
|
||||
|
||||
export async function getSendsByIds(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
ids: string[],
|
||||
userId: string
|
||||
): Promise<Send[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
if (!uniqueIds.length) return [];
|
||||
const chunkSize = sqlChunkSize(1);
|
||||
const out: Send[] = [];
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
const res = await db
|
||||
.prepare(
|
||||
`SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date
|
||||
FROM sends
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(userId, ...chunk)
|
||||
.all<any>();
|
||||
out.push(...(res.results || []).map((row) => mapSendRow(row)));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function bulkDeleteSends(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
updateRevisionDate: UpdateRevisionDate,
|
||||
ids: string[],
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
if (ids.length === 0) return null;
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
if (!uniqueIds.length) return null;
|
||||
const chunkSize = sqlChunkSize(1);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
await db.prepare(`DELETE FROM sends WHERE user_id = ? AND id IN (${placeholders})`).bind(userId, ...chunk).run();
|
||||
}
|
||||
|
||||
return updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
export async function getAllSends(db: D1Database, userId: string): Promise<Send[]> {
|
||||
const res = await db
|
||||
.prepare(
|
||||
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE user_id = ? ORDER BY updated_at DESC'
|
||||
)
|
||||
.bind(userId)
|
||||
.all<any>();
|
||||
return (res.results || []).map((row) => mapSendRow(row));
|
||||
}
|
||||
|
||||
export async function getSendsPage(db: D1Database, userId: string, limit: number, offset: number): Promise<Send[]> {
|
||||
const res = await db
|
||||
.prepare(
|
||||
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?'
|
||||
)
|
||||
.bind(userId, limit, offset)
|
||||
.all<any>();
|
||||
return (res.results || []).map((row) => mapSendRow(row));
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { User } from '../types';
|
||||
|
||||
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||
|
||||
function mapUserRow(row: any): User {
|
||||
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,
|
||||
role: row.role === 'admin' ? 'admin' : 'user',
|
||||
status: row.status === 'banned' ? 'banned' : 'active',
|
||||
totpSecret: row.totp_secret ?? null,
|
||||
totpRecoveryCode: row.totp_recovery_code ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUser(db: D1Database, email: string): Promise<User | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE email = ?'
|
||||
)
|
||||
.bind(email.toLowerCase())
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
return mapUserRow(row);
|
||||
}
|
||||
|
||||
export async function getUserById(db: D1Database, id: string): Promise<User | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE id = ?'
|
||||
)
|
||||
.bind(id)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
return mapUserRow(row);
|
||||
}
|
||||
|
||||
export async function getUserCount(db: D1Database): Promise<number> {
|
||||
const row = await db.prepare('SELECT COUNT(*) AS count FROM users').first<{ count: number }>();
|
||||
return Number(row?.count || 0);
|
||||
}
|
||||
|
||||
export async function getAllUsers(db: D1Database): Promise<User[]> {
|
||||
const res = await db
|
||||
.prepare(
|
||||
'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'
|
||||
)
|
||||
.all<any>();
|
||||
return (res.results || []).map((row) => mapUserRow(row));
|
||||
}
|
||||
|
||||
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = 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, role, status, totp_secret, totp_recovery_code, 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, role=excluded.role, status=excluded.status, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at'
|
||||
);
|
||||
await safeBind(
|
||||
stmt,
|
||||
user.id,
|
||||
email,
|
||||
user.name,
|
||||
user.masterPasswordHash,
|
||||
user.key,
|
||||
user.privateKey,
|
||||
user.publicKey,
|
||||
user.kdfType,
|
||||
user.kdfIterations,
|
||||
user.kdfMemory,
|
||||
user.kdfParallelism,
|
||||
user.securityStamp,
|
||||
user.role,
|
||||
user.status,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
).run();
|
||||
}
|
||||
|
||||
export async function createUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
|
||||
await saveUser(db, safeBind, user);
|
||||
}
|
||||
|
||||
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = 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, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
||||
);
|
||||
const result = await safeBind(
|
||||
stmt,
|
||||
user.id,
|
||||
email,
|
||||
user.name,
|
||||
user.masterPasswordHash,
|
||||
user.key,
|
||||
user.privateKey,
|
||||
user.publicKey,
|
||||
user.kdfType,
|
||||
user.kdfIterations,
|
||||
user.kdfMemory,
|
||||
user.kdfParallelism,
|
||||
user.securityStamp,
|
||||
user.role,
|
||||
user.status,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
).run();
|
||||
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function deleteUserById(db: D1Database, id: string): Promise<boolean> {
|
||||
const result = await db.prepare('DELETE FROM users WHERE id = ?').bind(id).run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
+213
-896
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user