From bb50617b168359892b2bcef53fa9c7029e9abdca Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 1 Mar 2026 05:55:42 +0800 Subject: [PATCH] feat: add PublicSendPage and SendsPage components for managing sends --- migrations/0001_init.sql | 26 + src/config/limits.ts | 11 + src/handlers/identity.ts | 35 + src/handlers/sends.ts | 1227 ++++++++++++++++++++++ src/handlers/sync.ts | 4 +- src/router.ts | 90 +- src/services/storage.ts | 111 +- src/types/index.ts | 58 +- src/utils/jwt.ts | 137 +++ webapp/src/App.tsx | 135 ++- webapp/src/components/AdminPage.tsx | 24 +- webapp/src/components/PublicSendPage.tsx | 131 +++ webapp/src/components/SendsPage.tsx | 419 ++++++++ webapp/src/lib/api.ts | 272 ++++- webapp/src/lib/types.ts | 46 + webapp/src/styles.css | 94 +- 16 files changed, 2792 insertions(+), 28 deletions(-) create mode 100644 src/handlers/sends.ts create mode 100644 webapp/src/components/PublicSendPage.tsx create mode 100644 webapp/src/components/SendsPage.tsx diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index bae2908..06b8928 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -76,6 +76,32 @@ CREATE TABLE IF NOT EXISTS attachments ( ); CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id); +CREATE TABLE IF NOT EXISTS sends ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + type INTEGER NOT NULL, + name TEXT NOT NULL, + notes TEXT, + data TEXT NOT NULL, + key TEXT NOT NULL, + password_hash TEXT, + password_salt TEXT, + password_iterations INTEGER, + auth_type INTEGER NOT NULL DEFAULT 2, + emails TEXT, + max_access_count INTEGER, + access_count INTEGER NOT NULL DEFAULT 0, + disabled INTEGER NOT NULL DEFAULT 0, + hide_email INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + expiration_date TEXT, + deletion_date TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at); +CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date); + CREATE TABLE IF NOT EXISTS refresh_tokens ( token TEXT PRIMARY KEY, user_id TEXT NOT NULL, diff --git a/src/config/limits.ts b/src/config/limits.ts index 341908d..a2725aa 100644 --- a/src/config/limits.ts +++ b/src/config/limits.ts @@ -15,6 +15,9 @@ // Attachment download token lifetime in seconds. // 附件下载令牌有效期(秒)。 fileDownloadTokenTtlSeconds: 300, + // Send access token lifetime in seconds. + // Send 访问令牌有效期(秒)。 + sendAccessTokenTtlSeconds: 300, // Minimum required JWT secret length. // JWT 密钥最小长度要求。 jwtSecretMinLength: 32, @@ -73,6 +76,14 @@ // 附件上传大小上限(字节)。 maxFileSizeBytes: 100 * 1024 * 1024, }, + send: { + // Max file size allowed for Send file uploads. + // Send 文件上传大小上限。 + maxFileSizeBytes: 550_502_400, + // Max days allowed between now and deletion date. + // 允许的最远删除日期(距当前天数)。 + maxDeletionDays: 31, + }, pagination: { // Default page size when client does not specify pageSize. // 客户端未传 pageSize 时的默认分页大小。 diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 78c0006..d7fc0e3 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -7,6 +7,7 @@ import { LIMITS } from '../config/limits'; import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { createRefreshToken } from '../utils/jwt'; import { readAuthRequestDeviceInfo } from '../utils/device'; +import { issueSendAccessToken } from './sends'; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; @@ -247,6 +248,40 @@ export async function handleToken(request: Request, env: Env): Promise return jsonResponse(response); + } else if (grantType === 'send_access') { + const sendId = String(body.send_id || body.sendId || '').trim(); + if (!sendId) { + return jsonResponse( + { + error: 'invalid_request', + error_description: 'send_id is required', + send_access_error_type: 'invalid_send_id', + ErrorModel: { + Message: 'send_id is required', + Object: 'error', + }, + }, + 400 + ); + } + + const passwordHashB64 = String( + body.password_hash_b64 || body.passwordHashB64 || body.passwordHash || body.password_hash || '' + ).trim() || null; + const password = String(body.password || '').trim() || null; + + const result = await issueSendAccessToken(env, sendId, passwordHashB64, password); + if ('error' in result) { + return result.error; + } + + return jsonResponse({ + access_token: result.token, + expires_in: LIMITS.auth.sendAccessTokenTtlSeconds, + token_type: 'Bearer', + scope: 'api.send', + unofficialServer: true, + }); } else if (grantType === 'refresh_token') { // Refresh token const refreshToken = body.refresh_token; diff --git a/src/handlers/sends.ts b/src/handlers/sends.ts new file mode 100644 index 0000000..f33ce69 --- /dev/null +++ b/src/handlers/sends.ts @@ -0,0 +1,1227 @@ +import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } 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 { + createSendAccessToken, + createSendFileDownloadToken, + verifySendAccessToken, + verifySendFileDownloadToken, +} from '../utils/jwt'; + +const SEND_INACCESSIBLE_MSG = 'Send does not exist or is no longer available'; +const SEND_PASSWORD_ITERATIONS = 100_000; + +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)[key]; + return { present: true, value }; + } + } + return { present: false, value: undefined }; +} + +function base64UrlEncode(data: Uint8Array): string { + const base64 = btoa(String.fromCharCode(...data)); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +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); +} + +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); +} + +async function resolveSendFromIdOrAccessId(storage: StorageService, idOrAccessId: string): Promise { + 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); +} + +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`; +} + +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; +} + +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; +} + +function sanitizeSendData(raw: unknown): Record | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + const data = { ...(raw as Record) }; + delete data.response; + return data; +} + +function parseStoredSendData(send: Send): Record { + try { + const parsed = JSON.parse(send.data) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return { ...(parsed as Record) }; + } + return {}; + } catch { + return {}; + } +} + +function normalizeSendDataSizeField(data: Record): Record { + const normalized = { ...data }; + if (typeof normalized.size === 'number' && Number.isFinite(normalized.size)) { + normalized.size = String(Math.trunc(normalized.size)); + } + return normalized; +} + +function getSendFilePath(sendId: string, fileId: string): string { + return `sends/${sendId}/${fileId}`; +} + +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 { + 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; +} + +async function setSendPassword(send: Send, password: string | null): Promise { + if (!password) { + send.passwordHash = null; + send.passwordSalt = null; + send.passwordIterations = null; + if (send.authType === SendAuthType.Password) { + send.authType = SendAuthType.None; + } + 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 { + if (!send.passwordHash || !send.passwordSalt || !send.passwordIterations) { + return false; + } + + 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); +} + +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; +} + +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 }; +} + +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 }; +} + +function parseSendType(value: unknown): SendType | null { + const type = parseInteger(value); + if (type === SendType.Text || type === SendType.File) return type; + return null; +} + +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; +} + +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; +} + +function hasEmailAuth(send: Send): boolean { + return send.authType === SendAuthType.Email; +} + +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', + }; +} + +function sendToAccessResponse(send: Send, creatorIdentifier: string | null): Record { + 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', + }; +} + +async function getCreatorIdentifier(storage: StorageService, send: Send): Promise { + if (send.hideEmail) return null; + const owner = await storage.getUserById(send.userId); + return owner?.email ?? null; +} + +async function validatePublicSendAccess(send: Send, body: unknown): Promise { + if (hasEmailAuth(send)) { + return errorResponse(SEND_INACCESSIBLE_MSG, 404); + } + + if (!send.passwordHash) return null; + + const passwordRaw = getAliasedProp(body, ['password', 'Password']); + if (typeof passwordRaw.value !== 'string') { + return errorResponse('Password not provided', 401); + } + + const validPassword = await verifySendPassword(send, passwordRaw.value); + if (!validPassword) { + return errorResponse('Invalid password', 400); + } + + return null; +} + +// GET /api/sends +export async function handleGetSends(request: Request, env: Env, userId: string): Promise { + 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, + }); +} + +// GET /api/sends/:id +export async function handleGetSend(request: Request, env: Env, userId: string, sendId: string): Promise { + 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)); +} + +// POST /api/sends +export async function handleCreateSend(request: Request, env: Env, userId: string): Promise { + 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); + await storage.updateRevisionDate(userId); + + return jsonResponse(sendToResponse(send)); +} + +// POST /api/sends/file/v2 +export async function handleCreateFileSendV2(request: Request, env: Env, userId: string): Promise { + 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 !== 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 > LIMITS.send.maxFileSizeBytes) { + 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); + await storage.updateRevisionDate(userId); + + return jsonResponse({ + fileUploadType: 0, + object: 'send-fileUpload', + url: `/api/sends/${send.id}/file/${fileId}`, + sendResponse: sendToResponse(send), + }); +} + +// GET /api/sends/:id/file/:fileId +export async function handleGetSendFileUpload( + request: Request, + env: Env, + userId: string, + sendId: string, + fileId: string +): Promise { + 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), + }); +} + +// POST /api/sends/:id/file/:fileId +export async function handleUploadSendFile( + request: Request, + env: Env, + userId: string, + sendId: string, + fileId: string +): Promise { + 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); + } + + 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 > LIMITS.send.maxFileSizeBytes) { + 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); + } + + await env.ATTACHMENTS.put(getSendFilePath(sendId, fileId), file.stream(), { + httpMetadata: { + contentType: 'application/octet-stream', + }, + customMetadata: { + sendId, + fileId, + }, + }); + + await storage.updateRevisionDate(userId); + + return new Response(null, { status: 200 }); +} + +// PUT /api/sends/:id +export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise { + 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); + await storage.updateRevisionDate(userId); + + return jsonResponse(sendToResponse(send)); +} + +// DELETE /api/sends/:id +export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise { + 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 env.ATTACHMENTS.delete(getSendFilePath(send.id, fileId)); + } + } + + await storage.deleteSend(sendId, userId); + await storage.updateRevisionDate(userId); + + return new Response(null, { status: 200 }); +} + +// PUT /api/sends/:id/remove-password +export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise { + 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); + await storage.updateRevisionDate(userId); + + return jsonResponse(sendToResponse(send)); +} + +// PUT /api/sends/:id/remove-auth +export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise { + 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); + await storage.updateRevisionDate(userId); + + return jsonResponse(sendToResponse(send)); +} + +// POST /api/sends/access/:accessId +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 = {}; + } + + const validationErr = await validatePublicSendAccess(send, body); + if (validationErr) { + return validationErr; + } + + if (send.type === SendType.Text) { + send.accessCount += 1; + send.updatedAt = new Date().toISOString(); + await storage.saveSend(send); + await storage.updateRevisionDate(send.userId); + } + + const creatorIdentifier = await getCreatorIdentifier(storage, send); + return jsonResponse(sendToAccessResponse(send, creatorIdentifier)); +} + +// POST /api/sends/:idOrAccess/access/file/:fileId +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 || secret === DEFAULT_DEV_SECRET) { + 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 = {}; + } + + const validationErr = await validatePublicSendAccess(send, body); + if (validationErr) { + return validationErr; + } + + send.accessCount += 1; + send.updatedAt = new Date().toISOString(); + await storage.saveSend(send); + await storage.updateRevisionDate(send.userId); + + 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, + }); +} + +// POST /api/sends/access (v2 bearer) +export async function handleAccessSendV2(request: Request, env: Env): Promise { + const token = extractBearerToken(request); + if (!token) { + return errorResponse('Unauthorized', 401); + } + + const claims = await verifySendAccessToken(token, env.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) { + send.accessCount += 1; + send.updatedAt = new Date().toISOString(); + await storage.saveSend(send); + await storage.updateRevisionDate(send.userId); + } + + const creatorIdentifier = await getCreatorIdentifier(storage, send); + return jsonResponse(sendToAccessResponse(send, creatorIdentifier)); +} + +// POST /api/sends/access/file/:fileId (v2 bearer) +export async function handleAccessSendFileV2(request: Request, env: Env, fileId: string): Promise { + const secret = (env.JWT_SECRET || '').trim(); + if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { + return errorResponse('Server configuration error', 500); + } + + const token = extractBearerToken(request); + if (!token) { + return errorResponse('Unauthorized', 401); + } + + const claims = await verifySendAccessToken(token, 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); + } + + send.accessCount += 1; + send.updatedAt = new Date().toISOString(); + await storage.saveSend(send); + await storage.updateRevisionDate(send.userId); + + const downloadToken = await createSendFileDownloadToken(send.id, fileId, 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, + }); +} + +// GET /api/sends/:sendId/:fileId?t=... +export async function handleDownloadSendFile( + request: Request, + env: Env, + sendId: string, + fileId: string +): Promise { + const secret = (env.JWT_SECRET || '').trim(); + if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { + return errorResponse('Server configuration error', 500); + } + + 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, secret); + if (!claims) { + return errorResponse('Invalid or expired token', 401); + } + if (claims.sendId !== sendId || claims.fileId !== fileId) { + return errorResponse('Token mismatch', 401); + } + + const object = await env.ATTACHMENTS.get(getSendFilePath(sendId, fileId)); + if (!object) { + return errorResponse('Send file not found', 404); + } + + return new Response(object.body, { + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(object.size), + 'Cache-Control': 'private, no-cache', + }, + }); +} + +export async function issueSendAccessToken( + env: Env, + sendId: string, + passwordHashB64?: string | null, + password?: string | null +): Promise<{ token: string } | { error: Response }> { + const storage = new StorageService(env.DB); + const send = await storage.getSend(sendId); + + 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) { + let ok = false; + if (passwordHashB64) { + ok = verifySendPasswordHashB64(send, passwordHashB64); + } else if (password) { + ok = await verifySendPassword(send, password); + } + + if (!ok) { + return { + error: jsonResponse( + { + error: 'invalid_grant', + error_description: 'Invalid password.', + send_access_error_type: 'invalid_password', + ErrorModel: { + Message: 'Invalid password.', + Object: 'error', + }, + }, + 400 + ), + }; + } + } + + const token = await createSendAccessToken(send.id, env.JWT_SECRET); + return { token }; +} + diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index e5f74f1..5e30d91 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -2,6 +2,7 @@ import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } fr import { StorageService } from '../services/storage'; import { errorResponse } from '../utils/response'; import { cipherToResponse } from './ciphers'; +import { sendToResponse } from './sends'; import { LIMITS } from '../config/limits'; import { isTotpEnabled } from '../utils/totp'; @@ -61,6 +62,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr const ciphers = await storage.getAllCiphers(userId); const folders = await storage.getAllFolders(userId); + const sends = await storage.getAllSends(userId); const attachmentsByCipher = await storage.getAttachmentsByUserId(userId); // Build profile response @@ -116,7 +118,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr object: 'domains', }, policies: [], - sends: [], + sends: sends.map(sendToResponse), // PascalCase for desktop/browser clients UserDecryptionOptions: { HasMasterPassword: true, diff --git a/src/router.ts b/src/router.ts index 8223b45..5b44032 100644 --- a/src/router.ts +++ b/src/router.ts @@ -44,6 +44,25 @@ import { handleDeleteFolder } from './handlers/folders'; +// Send handlers +import { + handleGetSends, + handleGetSend, + handleCreateSend, + handleCreateFileSendV2, + handleGetSendFileUpload, + handleUploadSendFile, + handleUpdateSend, + handleDeleteSend, + handleRemoveSendPassword, + handleRemoveSendAuth, + handleAccessSend, + handleAccessSendFile, + handleAccessSendV2, + handleAccessSendFileV2, + handleDownloadSendFile, +} from './handlers/sends'; + // Sync handler import { handleSync } from './handlers/sync'; @@ -229,6 +248,38 @@ export async function handleRequest(request: Request, env: Env): Promise { + const row = await this.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(); + if (!row) return null; + return this.mapSendRow(row); + } + + async saveSend(send: Send): Promise { + const stmt = this.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 this.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(); + } + + async deleteSend(id: string, userId: string): Promise { + await this.db.prepare('DELETE FROM sends WHERE id = ? AND user_id = ?').bind(id, userId).run(); + } + + async getAllSends(userId: string): Promise { + const res = await this.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(); + return (res.results || []).map(row => this.mapSendRow(row)); + } + + async getSendsPage(userId: string, limit: number, offset: number): Promise { + const res = await this.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(); + return (res.results || []).map(row => this.mapSendRow(row)); + } + async deleteRefreshTokensByUserId(userId: string): Promise { await this.db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').bind(userId).run(); } diff --git a/src/types/index.ts b/src/types/index.ts index 92cd0b8..faea882 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -183,6 +183,62 @@ export interface Device { updatedAt: string; } +export enum SendType { + Text = 0, + File = 1, +} + +export enum SendAuthType { + Email = 0, + Password = 1, + None = 2, +} + +export interface Send { + id: string; + userId: string; + type: SendType; + name: string; + notes: string | null; + data: string; + key: string; + passwordHash: string | null; + passwordSalt: string | null; + passwordIterations: number | null; + authType: SendAuthType; + emails: string | null; + maxAccessCount: number | null; + accessCount: number; + disabled: boolean; + hideEmail: boolean | null; + createdAt: string; + updatedAt: string; + expirationDate: string | null; + deletionDate: string; +} + +export interface SendResponse { + id: string; + accessId: string; + type: number; + name: string; + notes: string | null; + text: any | null; + file: any | null; + key: string; + maxAccessCount: number | null; + accessCount: number; + password: string | null; + emails: string | null; + authType: SendAuthType; + disabled: boolean; + hideEmail: boolean | null; + revisionDate: string; + expirationDate: string | null; + deletionDate: string; + object: string; +} + // JWT Payload export interface JWTPayload { sub: string; // user id @@ -318,7 +374,7 @@ export interface SyncResponse { ciphers: CipherResponse[]; domains: any; policies: any[]; - sends: any[]; + sends: SendResponse[]; // PascalCase for desktop/browser clients UserDecryptionOptions: UserDecryptionOptions | null; // camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption")) diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts index 67ee54e..01a1533 100644 --- a/src/utils/jwt.ts +++ b/src/utils/jwt.ts @@ -177,3 +177,140 @@ export async function verifyFileDownloadToken( return null; } } + +export interface SendFileDownloadClaims { + sendId: string; + fileId: string; + exp: number; +} + +export async function createSendFileDownloadToken( + sendId: string, + fileId: string, + secret: string +): Promise { + const header = { alg: 'HS256', typ: 'JWT' }; + const now = Math.floor(Date.now() / 1000); + const payload: SendFileDownloadClaims = { + sendId, + fileId, + exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds, + }; + + const encoder = new TextEncoder(); + const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header))); + const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload))); + const data = `${headerB64}.${payloadB64}`; + + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + + const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); + const signatureB64 = base64UrlEncode(new Uint8Array(signature)); + return `${data}.${signatureB64}`; +} + +export async function verifySendFileDownloadToken( + token: string, + secret: string +): Promise { + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + + const [headerB64, payloadB64, signatureB64] = parts; + const encoder = new TextEncoder(); + + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'] + ); + + const data = `${headerB64}.${payloadB64}`; + const signature = base64UrlDecode(signatureB64); + const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data)); + if (!valid) return null; + + const payload: SendFileDownloadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64))); + const now = Math.floor(Date.now() / 1000); + if (payload.exp < now) return null; + + return payload; + } catch { + return null; + } +} + +export interface SendAccessTokenClaims { + sub: string; // send id + typ: 'send_access'; + iat: number; + exp: number; +} + +export async function createSendAccessToken(sendId: string, secret: string): Promise { + const header = { alg: 'HS256', typ: 'JWT' }; + const now = Math.floor(Date.now() / 1000); + const payload: SendAccessTokenClaims = { + sub: sendId, + typ: 'send_access', + iat: now, + exp: now + LIMITS.auth.sendAccessTokenTtlSeconds, + }; + + const encoder = new TextEncoder(); + const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header))); + const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload))); + const data = `${headerB64}.${payloadB64}`; + + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); + const signatureB64 = base64UrlEncode(new Uint8Array(signature)); + return `${data}.${signatureB64}`; +} + +export async function verifySendAccessToken(token: string, secret: string): Promise { + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + + const [headerB64, payloadB64, signatureB64] = parts; + const encoder = new TextEncoder(); + + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'] + ); + + const data = `${headerB64}.${payloadB64}`; + const signature = base64UrlDecode(signatureB64); + const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data)); + if (!valid) return null; + + const payload: SendAccessTokenClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64))); + const now = Math.floor(Date.now() / 1000); + if (payload.exp < now) return null; + if (payload.typ !== 'send_access') return null; + if (!payload.sub) return null; + return payload; + } catch { + return null; + } +} diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index af0c9c1..0fba9c2 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1,11 +1,13 @@ import { useEffect, useMemo, useState } from 'preact/hooks'; import { Link, Route, Switch, useLocation } from 'wouter'; import { useQuery } from '@tanstack/react-query'; -import { CircleHelp, LogOut, Settings as SettingsIcon, Shield, ShieldUser, Vault } from 'lucide-preact'; +import { CircleHelp, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser, Vault } from 'lucide-preact'; import AuthViews from '@/components/AuthViews'; import ConfirmDialog from '@/components/ConfirmDialog'; import ToastHost from '@/components/ToastHost'; import VaultPage from '@/components/VaultPage'; +import SendsPage from '@/components/SendsPage'; +import PublicSendPage from '@/components/PublicSendPage'; import SettingsPage from '@/components/SettingsPage'; import AdminPage from '@/components/AdminPage'; import HelpPage from '@/components/HelpPage'; @@ -15,8 +17,10 @@ import { createCipher, createAuthedFetch, createInvite, + createSend, deleteAllInvites, deleteCipher, + deleteSend, deleteUser, deriveLoginHash, bulkMoveCiphers, @@ -24,6 +28,7 @@ import { getFolders, getProfile, getSetupStatus, + getSends, getTotpStatus, getWebConfig, listAdminInvites, @@ -36,12 +41,14 @@ import { setTotp, setUserStatus, updateCipher, + updateSend, + buildSendShareKey, unlockVaultKey, updateProfile, verifyMasterPassword, } from '@/lib/api'; import { base64ToBytes, decryptBw, decryptStr } from '@/lib/crypto'; -import type { AppPhase, Cipher, Folder, Profile, SessionState, ToastMessage, VaultDraft } from '@/lib/types'; +import type { AppPhase, Cipher, Folder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types'; interface PendingTotp { email: string; @@ -83,6 +90,7 @@ export default function App() { const [toasts, setToasts] = useState([]); const [decryptedFolders, setDecryptedFolders] = useState([]); const [decryptedCiphers, setDecryptedCiphers] = useState([]); + const [decryptedSends, setDecryptedSends] = useState([]); function setSession(next: SessionState | null) { setSessionState(next); @@ -302,6 +310,11 @@ export default function App() { queryFn: () => getFolders(authedFetch), enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey, }); + const sendsQuery = useQuery({ + queryKey: ['sends', session?.accessToken], + queryFn: () => getSends(authedFetch), + enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey, + }); const usersQuery = useQuery({ queryKey: ['admin-users', session?.accessToken], queryFn: () => listAdminUsers(authedFetch), @@ -322,9 +335,10 @@ export default function App() { if (!session?.symEncKey || !session?.symMacKey) { setDecryptedFolders([]); setDecryptedCiphers([]); + setDecryptedSends([]); return; } - if (!foldersQuery.data || !ciphersQuery.data) return; + if (!foldersQuery.data || !ciphersQuery.data || !sendsQuery.data) return; let active = true; (async () => { @@ -440,9 +454,36 @@ export default function App() { }) ); + const sends = await Promise.all( + sendsQuery.data.map(async (send) => { + const nextSend: Send = { ...send }; + try { + if (send.key) { + const sendKeyRaw = await decryptBw(send.key, encKey, macKey); + const sendEnc = sendKeyRaw.slice(0, 32); + const sendMac = sendKeyRaw.slice(32, 64); + nextSend.decName = await decryptField(send.name || '', sendEnc, sendMac); + nextSend.decNotes = await decryptField(send.notes || '', sendEnc, sendMac); + nextSend.decText = await decryptField(send.text?.text || '', sendEnc, sendMac); + const shareKey = await buildSendShareKey(send.key, session.symEncKey!, session.symMacKey!); + nextSend.decShareKey = shareKey; + nextSend.shareUrl = `${window.location.origin}/send/${send.accessId}/${shareKey}`; + } else { + nextSend.decName = ''; + nextSend.decNotes = ''; + nextSend.decText = ''; + } + } catch { + nextSend.decName = '(Decrypt failed)'; + } + return nextSend; + }) + ); + if (!active) return; setDecryptedFolders(folders); setDecryptedCiphers(ciphers); + setDecryptedSends(sends); } catch (error) { if (!active) return; pushToast('error', error instanceof Error ? error.message : 'Decrypt failed'); @@ -452,7 +493,7 @@ export default function App() { return () => { active = false; }; - }, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data]); + }, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data, sendsQuery.data]); async function saveProfileAction(name: string, email: string) { try { @@ -526,7 +567,7 @@ export default function App() { } async function refreshVault() { - await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); + await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch(), sendsQuery.refetch()]); pushToast('success', 'Vault synced'); } @@ -589,6 +630,64 @@ export default function App() { } } + async function createSendItem(draft: SendDraft, autoCopyLink: boolean) { + if (!session) return; + try { + const created = await createSend(authedFetch, session, draft); + await sendsQuery.refetch(); + if (autoCopyLink && created.key && session.symEncKey && session.symMacKey) { + const keyPart = await buildSendShareKey(created.key, session.symEncKey, session.symMacKey); + const shareUrl = `${window.location.origin}/send/${created.accessId}/${keyPart}`; + await navigator.clipboard.writeText(shareUrl); + } + pushToast('success', 'Send created'); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : 'Create send failed'); + throw error; + } + } + + async function updateSendItem(send: Send, draft: SendDraft, autoCopyLink: boolean) { + if (!session) return; + try { + const updated = await updateSend(authedFetch, session, send, draft); + await sendsQuery.refetch(); + if (autoCopyLink && updated.key && session.symEncKey && session.symMacKey) { + const keyPart = await buildSendShareKey(updated.key, session.symEncKey, session.symMacKey); + const shareUrl = `${window.location.origin}/send/${updated.accessId}/${keyPart}`; + await navigator.clipboard.writeText(shareUrl); + } + pushToast('success', 'Send updated'); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : 'Update send failed'); + throw error; + } + } + + async function deleteSendItem(send: Send) { + try { + await deleteSend(authedFetch, send.id); + await sendsQuery.refetch(); + pushToast('success', 'Send deleted'); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : 'Delete send failed'); + throw error; + } + } + + async function bulkDeleteSendItems(ids: string[]) { + try { + for (const id of ids) { + await deleteSend(authedFetch, id); + } + await sendsQuery.refetch(); + pushToast('success', 'Deleted selected sends'); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : 'Bulk delete sends failed'); + throw error; + } + } + async function verifyMasterPasswordAction(email: string, password: string) { const derived = await deriveLoginHash(email, password, defaultKdfIterations); await verifyMasterPassword(authedFetch, derived.hash); @@ -614,6 +713,16 @@ export default function App() { if (phase === 'app' && location === '/') navigate('/vault'); }, [phase, location, navigate]); + const publicSendMatch = location.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i); + if (publicSendMatch) { + return ( + <> + + setToasts((prev) => prev.filter((x) => x.id !== id))} /> + + ); + } + if (phase === 'loading') { return ( <> @@ -695,6 +804,10 @@ export default function App() { My Vault + + + Sends + {profile?.role === 'admin' && ( @@ -712,6 +825,18 @@ export default function App() {
+ + +
-
- setInviteHours(Number((e.currentTarget as HTMLInputElement).value || 168))} - /> - hours +
+
+ + )} + + {!loading && sendData && ( + <> +

{sendData.decName || '(No Name)'}

+ {sendData.type === 0 ? ( +
+
{sendData.decText || ''}
+
+ ) : ( +
+
+ File + {sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || 'Encrypted File'} +
+ +
+ )} + {!!sendData.expirationDate &&

Expires at: {sendData.expirationDate}

} + + )} + + {!loading && !sendData && !needPassword && !error && ( +

+ Send unavailable. +

+ )} + {!!error &&

{error}

} +
+
+ ); +} diff --git a/webapp/src/components/SendsPage.tsx b/webapp/src/components/SendsPage.tsx new file mode 100644 index 0000000..a1fee8b --- /dev/null +++ b/webapp/src/components/SendsPage.tsx @@ -0,0 +1,419 @@ +import { useEffect, useMemo, useState } from 'preact/hooks'; +import { Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Send as SendIcon, Trash2 } from 'lucide-preact'; +import type { Send, SendDraft } from '@/lib/types'; + +interface SendsPageProps { + sends: Send[]; + loading: boolean; + onRefresh: () => Promise; + onCreate: (draft: SendDraft, autoCopyLink: boolean) => Promise; + onUpdate: (send: Send, draft: SendDraft, autoCopyLink: boolean) => Promise; + onDelete: (send: Send) => Promise; + onBulkDelete: (ids: string[]) => Promise; + onNotify: (type: 'success' | 'error', text: string) => void; +} + +type SendTypeFilter = 'all' | 'text' | 'file'; +const AUTO_COPY_KEY = 'nodewarden.send.auto_copy_link.v1'; + +function daysFromNow(iso: string | null | undefined, fallback: number): string { + if (!iso) return String(fallback); + const d = new Date(iso).getTime(); + if (!Number.isFinite(d)) return String(fallback); + const diff = d - Date.now(); + const days = Math.ceil(diff / (24 * 60 * 60 * 1000)); + return String(Math.max(days, 0)); +} + +function buildDefaultDraft(): SendDraft { + return { + type: 'text', + name: '', + notes: '', + text: '', + file: null, + deletionDays: '7', + expirationDays: '0', + maxAccessCount: '', + password: '', + disabled: false, + }; +} + +function draftFromSend(send: Send): SendDraft { + return { + id: send.id, + type: Number(send.type) === 1 ? 'file' : 'text', + name: send.decName || '', + notes: send.decNotes || '', + text: send.decText || '', + file: null, + deletionDays: daysFromNow(send.deletionDate, 7), + expirationDays: daysFromNow(send.expirationDate, 0), + maxAccessCount: send.maxAccessCount !== null && send.maxAccessCount !== undefined ? String(send.maxAccessCount) : '', + password: '', + disabled: !!send.disabled, + }; +} + +export default function SendsPage(props: SendsPageProps) { + const [search, setSearch] = useState(''); + const [typeFilter, setTypeFilter] = useState('all'); + const [selectedId, setSelectedId] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [busy, setBusy] = useState(false); + const [draft, setDraft] = useState(null); + const [showPassword, setShowPassword] = useState(false); + const [selectedMap, setSelectedMap] = useState>({}); + const [autoCopyLink, setAutoCopyLink] = useState(() => { + try { + return localStorage.getItem(AUTO_COPY_KEY) === '1'; + } catch { + return false; + } + }); + + useEffect(() => { + try { + localStorage.setItem(AUTO_COPY_KEY, autoCopyLink ? '1' : '0'); + } catch { + // ignore storage errors + } + }, [autoCopyLink]); + + const filteredSends = useMemo(() => { + const q = search.trim().toLowerCase(); + return props.sends.filter((send) => { + if (typeFilter === 'text' && Number(send.type) !== 0) return false; + if (typeFilter === 'file' && Number(send.type) !== 1) return false; + if (!q) return true; + const name = (send.decName || '').toLowerCase(); + const text = (send.decText || '').toLowerCase(); + return name.includes(q) || text.includes(q); + }); + }, [props.sends, search, typeFilter]); + + useEffect(() => { + if (!filteredSends.length) { + setSelectedId(null); + return; + } + if (!selectedId || !filteredSends.some((x) => x.id === selectedId)) { + setSelectedId(filteredSends[0].id); + setIsEditing(false); + setIsCreating(false); + setDraft(null); + } + }, [filteredSends, selectedId]); + + const selectedSend = useMemo( + () => props.sends.find((x) => x.id === selectedId) || null, + [props.sends, selectedId] + ); + const selectedIds = useMemo(() => Object.keys(selectedMap).filter((id) => selectedMap[id]), [selectedMap]); + const selectedCount = selectedIds.length; + + async function saveDraft(): Promise { + if (!draft) return; + if (!draft.name.trim()) { + props.onNotify('error', 'Name is required'); + return; + } + if (draft.type === 'text' && !draft.text.trim()) { + props.onNotify('error', 'Text is required'); + return; + } + if (draft.type === 'file' && isCreating && !draft.file) { + props.onNotify('error', 'Please select a file'); + return; + } + setBusy(true); + try { + if (isCreating) { + await props.onCreate(draft, autoCopyLink); + setSelectedId(null); + } else if (selectedSend) { + await props.onUpdate(selectedSend, draft, autoCopyLink); + } + setIsEditing(false); + setIsCreating(false); + setDraft(null); + setShowPassword(false); + } finally { + setBusy(false); + } + } + + async function removeSend(send: Send): Promise { + setBusy(true); + try { + await props.onDelete(send); + if (selectedId === send.id) setSelectedId(null); + setIsEditing(false); + setDraft(null); + } finally { + setBusy(false); + } + } + + async function removeSelected(): Promise { + if (!selectedCount) return; + setBusy(true); + try { + await props.onBulkDelete(selectedIds); + setSelectedMap({}); + } finally { + setBusy(false); + } + } + + function copyAccessUrl(send: Send): void { + const url = send.shareUrl || `${window.location.origin}/send/${send.accessId}`; + void navigator.clipboard.writeText(url); + props.onNotify('success', 'Link copied'); + } + + return ( +
+ + +
+
+ setSearch((e.currentTarget as HTMLInputElement).value)} + /> + +
+
+ + + {!!selectedCount && ( + + )} + +
+
+ {filteredSends.map((send) => ( +
+ + setSelectedMap((prev) => ({ + ...prev, + [send.id]: (e.currentTarget as HTMLInputElement).checked, + })) + } + /> + +
+ ))} + {!filteredSends.length &&
No sends
} +
+
+ +
+ {isEditing && draft && ( +
+

{isCreating ? 'New Send' : 'Edit Send'}

+
+ + + {draft.type === 'file' ? ( + + ) : ( +