Files
nodewarden/src/handlers/sends-private.ts
T

693 lines
24 KiB
TypeScript

import { Env, Send, SendAuthType, SendType } from '../types';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
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 { createSendFileUploadToken, verifySendFileUploadToken } from '../utils/jwt';
import {
formatSize,
getAliasedProp,
normalizeEmails,
notifyVaultSyncForRequest,
parseDate,
parseFileLength,
parseInteger,
parseMaxAccessCount,
parseSendAuthType,
parseSendType,
parseStoredSendData,
sanitizeSendData,
sendToResponse,
setSendPassword,
validateDeletionDate,
} from './sends-shared';
async function processSendFileUpload(
request: Request,
env: Env,
send: Send,
fileId: string
): Promise<Response> {
const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes);
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 expectedFileName = typeof sendData.fileName === 'string' ? sendData.fileName : null;
const expectedSize = parseInteger(sendData.size);
const upload = await parseDirectUploadPayload(request, {
expectedSize,
expectedFileName,
maxFileSize,
tooLargeMessage: 'Send storage limit exceeded with this file',
sizeMismatchMessage: 'Send file size does not match.',
fileNameMismatchMessage: 'Send file name does not match.',
});
if (upload instanceof Response) {
return upload;
}
try {
await putBlobObject(env, getSendFileObjectKey(send.id, fileId), upload.body, {
size: upload.size,
contentType: upload.contentType,
customMetadata: {
sendId: send.id,
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 storage = new StorageService(env.DB);
const revisionDate = await storage.updateRevisionDate(send.userId);
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
return new Response(null, { status: 201 });
}
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);
}
const sendResponses = sends.map(sendToResponse);
return jsonResponse({
data: sendResponses,
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);
const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) {
return errorResponse('Server configuration error', 500);
}
const uploadToken = await createSendFileUploadToken(userId, send.id, fileId, jwtSecret);
return jsonResponse({
fileUploadType: 1,
object: 'send-fileUpload',
url: buildDirectUploadUrl(request, `/api/sends/${send.id}/file/${fileId}`, uploadToken),
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);
}
const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) {
return errorResponse('Server configuration error', 500);
}
const uploadToken = await createSendFileUploadToken(userId, send.id, fileId, jwtSecret);
return jsonResponse({
fileUploadType: 1,
object: 'send-fileUpload',
url: buildDirectUploadUrl(request, `/api/sends/${send.id}/file/${fileId}`, uploadToken),
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 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);
}
return processSendFileUpload(request, env, send, fileId);
}
export async function handlePublicUploadSendFile(
request: Request,
env: Env,
sendId: string,
fileId: string
): Promise<Response> {
const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) {
return errorResponse('Server configuration error', 500);
}
const token = new URL(request.url).searchParams.get('token');
if (!token) {
return errorResponse('Token required', 401);
}
const claims = await verifySendFileUploadToken(token, jwtSecret);
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 send = await storage.getSend(sendId);
if (!send || send.userId !== claims.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);
}
return processSendFileUpload(request, env, send, fileId);
}
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));
}