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 { 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); 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 { 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); 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 { 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); 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 { 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); 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 { 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 }; }