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

401 lines
12 KiB
TypeScript

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