From bb3fe413307fe75f08ad3dc7585421add939b77c Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Wed, 18 Mar 2026 02:26:10 +0800 Subject: [PATCH] feat: implement direct file upload for sends with JWT token validation - Added `processSendFileUpload` function to handle file uploads for sends. - Integrated JWT token creation and verification for secure file uploads. - Updated `handleCreateFileSendV2` and `handleGetSendFileUpload` to use new upload URL generation. - Refactored upload handling in `handleUploadSendFile` and `handlePublicUploadSendFile` to utilize the new upload process. - Introduced `uploadDirectEncryptedPayload` for handling direct uploads with progress tracking. - Enhanced API routes to support both POST and PUT methods for attachment uploads. - Added localization strings for upload progress messages. - Created utility functions for direct upload URL building and payload parsing. --- src/handlers/attachments.ts | 143 ++++++++++++------- src/handlers/sends-private.ts | 145 ++++++++++++------- src/router-authenticated.ts | 2 +- src/router-public.ts | 12 ++ src/utils/direct-upload.ts | 104 ++++++++++++++ src/utils/jwt.ts | 148 ++++++++++++++++++++ webapp/src/App.tsx | 4 + webapp/src/components/AdminPage.tsx | 2 +- webapp/src/components/AppMainRoutes.tsx | 8 ++ webapp/src/components/SendsPage.tsx | 10 ++ webapp/src/components/VaultPage.tsx | 4 + webapp/src/components/vault/VaultEditor.tsx | 10 ++ webapp/src/hooks/useVaultSendActions.ts | 43 +++++- webapp/src/lib/api/send.ts | 21 +-- webapp/src/lib/api/shared.ts | 96 +++++++++++++ webapp/src/lib/api/vault.ts | 18 ++- webapp/src/lib/i18n.ts | 23 ++- 17 files changed, 666 insertions(+), 127 deletions(-) create mode 100644 src/utils/direct-upload.ts diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index caf29f9..de304c7 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -2,8 +2,14 @@ import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types'; import { notifyUserVaultSync } from '../durable/notifications-hub'; 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 { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt'; +import { + createAttachmentUploadToken, + createFileDownloadToken, + verifyAttachmentUploadToken, + verifyFileDownloadToken, +} from '../utils/jwt'; import { cipherToResponse, shouldOmitPasskeysForResponse } from './ciphers'; import { LIMITS } from '../config/limits'; import { readActingDeviceIdentifier } from '../utils/device'; @@ -32,6 +38,55 @@ function formatSize(bytes: number): string { return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } +async function processAttachmentUpload( + request: Request, + env: Env, + attachment: Attachment, + cipherId: string +): Promise { + const storage = new StorageService(env.DB); + const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.attachment.maxFileSizeBytes); + const upload = await parseDirectUploadPayload(request, { + expectedSize: Number(attachment.size) || 0, + maxFileSize, + tooLargeMessage: `File too large. Maximum size is ${Math.floor(maxFileSize / (1024 * 1024))}MB`, + }); + if (upload instanceof Response) { + return upload; + } + + const path = getAttachmentObjectKey(cipherId, attachment.id); + try { + await putBlobObject(env, path, upload.body, { + size: upload.size, + contentType: upload.contentType, + customMetadata: { + cipherId, + attachmentId: attachment.id, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('KV object too large')) { + return errorResponse(`File too large. Maximum size is ${Math.floor(maxFileSize / (1024 * 1024))}MB`, 413); + } + return errorResponse('Attachment storage is not configured', 500); + } + + if (upload.size !== attachment.size) { + attachment.size = upload.size; + attachment.sizeName = formatSize(upload.size); + await storage.saveAttachment(attachment); + } + + const revisionInfo = await storage.updateCipherRevisionDate(cipherId); + if (revisionInfo) { + await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate); + } + + return new Response(null, { status: 201 }); +} + // POST /api/ciphers/{cipherId}/attachment/v2 // Creates attachment metadata and returns upload URL export async function handleCreateAttachment( @@ -92,12 +147,17 @@ export async function handleCreateAttachment( // Get updated cipher for response const updatedCipher = await storage.getCipher(cipherId); const attachments = await storage.getAttachmentsByCipher(cipherId); + const jwtSecret = getSafeJwtSecret(env); + if (!jwtSecret) { + return errorResponse('Server configuration error', 500); + } + const uploadToken = await createAttachmentUploadToken(userId, cipherId, attachmentId, jwtSecret); return jsonResponse({ object: 'attachment-fileUpload', attachmentId: attachmentId, - url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`, - fileUploadType: 0, // Direct upload + url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken), + fileUploadType: 1, cipherResponse: cipherToResponse(updatedCipher!, attachments, { omitFido2Credentials: shouldOmitPasskeysForResponse(request), }), @@ -114,7 +174,6 @@ export async function handleUploadAttachment( attachmentId: string ): Promise { const storage = new StorageService(env.DB); - const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.attachment.maxFileSizeBytes); // Verify cipher exists and belongs to user const cipher = await storage.getCipher(cipherId); @@ -128,63 +187,45 @@ export async function handleUploadAttachment( return errorResponse('Attachment not found', 404); } - // Check content-length header for size limit - const contentLength = request.headers.get('content-length'); - if (contentLength && parseInt(contentLength) > maxFileSize) { - return errorResponse(`File too large. Maximum size is ${Math.floor(maxFileSize / (1024 * 1024))}MB`, 413); + return processAttachmentUpload(request, env, attachment, cipherId); +} + +export async function handlePublicUploadAttachment( + request: Request, + env: Env, + cipherId: string, + attachmentId: string +): Promise { + const jwtSecret = getSafeJwtSecret(env); + if (!jwtSecret) { + return errorResponse('Server configuration error', 500); } - // Get the file from multipart form data - const contentType = request.headers.get('content-type') || ''; - if (!contentType.includes('multipart/form-data')) { - return errorResponse('Content-Type must be multipart/form-data', 400); + const token = new URL(request.url).searchParams.get('token'); + if (!token) { + return errorResponse('Token required', 401); } - const formData = await request.formData(); - const file = formData.get('data') as File | null; - - if (!file) { - return errorResponse('No file uploaded', 400); + const claims = await verifyAttachmentUploadToken(token, jwtSecret); + if (!claims) { + return errorResponse('Invalid or expired token', 401); + } + if (claims.cipherId !== cipherId || claims.attachmentId !== attachmentId) { + return errorResponse('Token mismatch', 401); } - // Check actual file size - if (file.size > maxFileSize) { - return errorResponse(`File too large. Maximum size is ${Math.floor(maxFileSize / (1024 * 1024))}MB`, 413); + const storage = new StorageService(env.DB); + const cipher = await storage.getCipher(cipherId); + if (!cipher || cipher.userId !== claims.userId) { + return errorResponse('Cipher not found', 404); } - const path = getAttachmentObjectKey(cipherId, attachmentId); - try { - await putBlobObject(env, path, file.stream(), { - size: file.size, - contentType: 'application/octet-stream', - customMetadata: { - cipherId, - attachmentId, - }, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes('KV object too large')) { - return errorResponse(`File too large. Maximum size is ${Math.floor(maxFileSize / (1024 * 1024))}MB`, 413); - } - return errorResponse('Attachment storage is not configured', 500); + const attachment = await storage.getAttachment(attachmentId); + if (!attachment || attachment.cipherId !== cipherId) { + return errorResponse('Attachment not found', 404); } - // Update attachment size if different - const actualSize = file.size; - if (actualSize !== attachment.size) { - attachment.size = actualSize; - attachment.sizeName = formatSize(actualSize); - await storage.saveAttachment(attachment); - } - - // Update cipher revision date - const revisionInfo = await storage.updateCipherRevisionDate(cipherId); - if (revisionInfo) { - await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate); - } - - return new Response(null, { status: 200 }); + return processAttachmentUpload(request, env, attachment, cipherId); } // GET /api/ciphers/{cipherId}/attachment/{attachmentId} diff --git a/src/handlers/sends-private.ts b/src/handlers/sends-private.ts index d3d19a0..337ce0e 100644 --- a/src/handlers/sends-private.ts +++ b/src/handlers/sends-private.ts @@ -1,6 +1,7 @@ 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'; @@ -10,6 +11,7 @@ import { putBlobObject, deleteBlobObject, } from '../services/blob-store'; +import { createSendFileUploadToken, verifySendFileUploadToken } from '../utils/jwt'; import { formatSize, getAliasedProp, @@ -28,6 +30,57 @@ import { validateDeletionDate, } from './sends-shared'; +async function processSendFileUpload( + request: Request, + env: Env, + send: Send, + fileId: string +): Promise { + 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 { const storage = new StorageService(env.DB); const url = new URL(request.url); @@ -296,11 +349,16 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId: 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: 0, + fileUploadType: 1, object: 'send-fileUpload', - url: `/api/sends/${send.id}/file/${fileId}`, + url: buildDirectUploadUrl(request, `/api/sends/${send.id}/file/${fileId}`, uploadToken), sendResponse: sendToResponse(send), }); } @@ -327,11 +385,16 @@ export async function handleGetSendFileUpload( 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: 0, + fileUploadType: 1, object: 'send-fileUpload', - url: `/api/sends/${send.id}/file/${fileId}`, + url: buildDirectUploadUrl(request, `/api/sends/${send.id}/file/${fileId}`, uploadToken), sendResponse: sendToResponse(send), }); } @@ -344,7 +407,6 @@ export async function handleUploadSendFile( fileId: string ): Promise { const storage = new StorageService(env.DB); - const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes); const send = await storage.getSend(sendId); if (!send || send.userId !== userId) { return errorResponse('Send not found. Unable to save the file.', 404); @@ -353,58 +415,43 @@ export async function handleUploadSendFile( 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 processSendFileUpload(request, env, send, fileId); +} + +export async function handlePublicUploadSendFile( + request: Request, + env: Env, + sendId: string, + fileId: string +): Promise { + const jwtSecret = getSafeJwtSecret(env); + if (!jwtSecret) { + return errorResponse('Server configuration error', 500); } - const contentType = request.headers.get('content-type') || ''; - if (!contentType.includes('multipart/form-data')) { - return errorResponse('Content-Type must be multipart/form-data', 400); + const token = new URL(request.url).searchParams.get('token'); + if (!token) { + return errorResponse('Token required', 401); } - const formData = await request.formData(); - const file = formData.get('data') as File | null; - if (!file) { - return errorResponse('No file uploaded', 400); + 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); } - if (file.size > maxFileSize) { - return errorResponse('Send storage limit exceeded with this file', 413); + 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); } - const expectedFileName = typeof sendData.fileName === 'string' ? sendData.fileName : null; - if (expectedFileName && file.name !== expectedFileName) { - return errorResponse('Send file name does not match.', 400); - } - - const expectedSize = parseInteger(sendData.size); - if (expectedSize !== null && file.size !== expectedSize) { - return errorResponse('Send file size does not match.', 400); - } - - try { - await putBlobObject(env, getSendFileObjectKey(sendId, fileId), file.stream(), { - size: file.size, - contentType: 'application/octet-stream', - customMetadata: { - sendId, - fileId, - }, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes('KV object too large')) { - return errorResponse('Send storage limit exceeded with this file', 413); - } - return errorResponse('Attachment storage is not configured', 500); - } - - const revisionDate = await storage.updateRevisionDate(userId); - await notifyVaultSyncForRequest(request, env, userId, revisionDate); - - return new Response(null, { status: 200 }); + return processSendFileUpload(request, env, send, fileId); } export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise { diff --git a/src/router-authenticated.ts b/src/router-authenticated.ts index 1a92d28..8a63c02 100644 --- a/src/router-authenticated.ts +++ b/src/router-authenticated.ts @@ -165,7 +165,7 @@ export async function handleAuthenticatedRoute( const attachmentMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)$/i); if (attachmentMatch) { const attachmentId = attachmentMatch[1]; - if (method === 'POST') return handleUploadAttachment(request, env, userId, cipherId, attachmentId); + if (method === 'POST' || method === 'PUT') return handleUploadAttachment(request, env, userId, cipherId, attachmentId); if (method === 'GET') return handleGetAttachment(request, env, userId, cipherId, attachmentId); if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId); } diff --git a/src/router-public.ts b/src/router-public.ts index 561ab5b..e3c5fd4 100644 --- a/src/router-public.ts +++ b/src/router-public.ts @@ -14,10 +14,12 @@ import { handleRecoverTwoFactor, } from './handlers/accounts'; import { handlePublicDownloadAttachment } from './handlers/attachments'; +import { handlePublicUploadAttachment } from './handlers/attachments'; import { handleNotificationsHub, handleNotificationsNegotiate, } from './handlers/notifications'; +import { handlePublicUploadSendFile } from './handlers/sends'; import { jsonResponse } from './utils/response'; import type { Env } from './types'; @@ -162,6 +164,16 @@ export async function handlePublicRoute( return handlePublicDownloadAttachment(request, env, publicAttachmentMatch[1], publicAttachmentMatch[2]); } + const publicAttachmentUploadMatch = path.match(/^\/api\/ciphers\/([a-f0-9-]+)\/attachment\/([a-f0-9-]+)$/i); + if (publicAttachmentUploadMatch && (method === 'POST' || method === 'PUT') && new URL(request.url).searchParams.has('token')) { + return handlePublicUploadAttachment(request, env, publicAttachmentUploadMatch[1], publicAttachmentUploadMatch[2]); + } + + const publicSendUploadMatch = path.match(/^\/api\/sends\/([^/]+)\/file\/([^/]+)\/?$/i); + if (publicSendUploadMatch && (method === 'POST' || method === 'PUT') && new URL(request.url).searchParams.has('token')) { + return handlePublicUploadSendFile(request, env, publicSendUploadMatch[1], publicSendUploadMatch[2]); + } + const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i); if (sendAccessMatch && method === 'POST') { const blocked = await enforcePublicRateLimit(); diff --git a/src/utils/direct-upload.ts b/src/utils/direct-upload.ts new file mode 100644 index 0000000..f1a6f23 --- /dev/null +++ b/src/utils/direct-upload.ts @@ -0,0 +1,104 @@ +import { LIMITS } from '../config/limits'; +import { DEFAULT_DEV_SECRET, Env } from '../types'; +import { errorResponse } from './response'; + +export interface DirectUploadPayload { + body: ReadableStream; + contentType: string; + size: number; +} + +interface ParseDirectUploadOptions { + expectedSize?: number | null; + expectedFileName?: string | null; + maxFileSize: number; + tooLargeMessage: string; + missingBodyMessage?: string; + contentLengthRequiredMessage?: string; + sizeMismatchMessage?: string; + fileNameMismatchMessage?: string; +} + +export function buildDirectUploadUrl(request: Request, path: string, token: string): string { + const version = '2023-11-03'; + const expiresAt = '2099-12-31T23:59:59Z'; + const origin = new URL(request.url).origin; + return `${origin}${path}?sv=${encodeURIComponent(version)}&se=${encodeURIComponent(expiresAt)}&token=${encodeURIComponent(token)}`; +} + +export function getSafeJwtSecret(env: Env): string | null { + const secret = (env.JWT_SECRET || '').trim(); + if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { + return null; + } + return secret; +} + +function parseContentLength(request: Request): number | null { + const raw = request.headers.get('content-length'); + if (!raw) return null; + const value = Number(raw); + if (!Number.isFinite(value) || value < 0) return null; + return Math.floor(value); +} + +export async function parseDirectUploadPayload( + request: Request, + options: ParseDirectUploadOptions +): Promise { + const { + expectedSize = null, + expectedFileName = null, + maxFileSize, + tooLargeMessage, + missingBodyMessage = 'No file uploaded', + contentLengthRequiredMessage = 'Content-Length is required for direct uploads', + sizeMismatchMessage, + fileNameMismatchMessage, + } = options; + const contentType = request.headers.get('content-type') || ''; + + if (contentType.includes('multipart/form-data')) { + const formData = await request.formData(); + const file = formData.get('data') as File | null; + if (!file) { + return errorResponse(missingBodyMessage, 400); + } + if (file.size > maxFileSize) { + return errorResponse(tooLargeMessage, 413); + } + if (expectedFileName && file.name !== expectedFileName) { + return errorResponse(fileNameMismatchMessage || 'File name does not match.', 400); + } + if (expectedSize !== null && expectedSize !== undefined && file.size !== expectedSize) { + return errorResponse(sizeMismatchMessage || 'File size does not match.', 400); + } + return { + body: file.stream(), + contentType: file.type || 'application/octet-stream', + size: file.size, + }; + } + + if (!request.body) { + return errorResponse(missingBodyMessage, 400); + } + + const declaredSize = parseContentLength(request); + const uploadSize = declaredSize ?? (expectedSize && expectedSize > 0 ? expectedSize : null); + if (uploadSize === null) { + return errorResponse(contentLengthRequiredMessage, 400); + } + if (uploadSize > maxFileSize) { + return errorResponse(tooLargeMessage, 413); + } + if (expectedSize !== null && expectedSize !== undefined && uploadSize !== expectedSize) { + return errorResponse(sizeMismatchMessage || 'File size does not match.', 400); + } + + return { + body: request.body, + contentType: contentType || 'application/octet-stream', + size: uploadSize, + }; +} diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts index c15f603..1c3c1fb 100644 --- a/src/utils/jwt.ts +++ b/src/utils/jwt.ts @@ -104,6 +104,13 @@ export interface FileDownloadClaims { exp: number; } +export interface AttachmentUploadClaims { + userId: string; + cipherId: string; + attachmentId: string; + exp: number; +} + // Create file download token (short-lived, 5 minutes) export async function createFileDownloadToken( cipherId: string, @@ -178,6 +185,73 @@ export async function verifyFileDownloadToken( } } +export async function createAttachmentUploadToken( + userId: string, + cipherId: string, + attachmentId: string, + secret: string +): Promise { + const header = { alg: 'HS256', typ: 'JWT' }; + const now = Math.floor(Date.now() / 1000); + const payload: AttachmentUploadClaims = { + userId, + cipherId, + attachmentId, + 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 verifyAttachmentUploadToken( + 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: AttachmentUploadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64))); + const now = Math.floor(Date.now() / 1000); + if (payload.exp < now) return null; + if (!payload.userId || !payload.cipherId || !payload.attachmentId) return null; + return payload; + } catch { + return null; + } +} + export interface SendFileDownloadClaims { sendId: string; fileId: string; @@ -185,6 +259,13 @@ export interface SendFileDownloadClaims { exp: number; } +export interface SendFileUploadClaims { + userId: string; + sendId: string; + fileId: string; + exp: number; +} + export async function createSendFileDownloadToken( sendId: string, fileId: string, @@ -260,6 +341,73 @@ export async function verifySendFileDownloadToken( } } +export async function createSendFileUploadToken( + userId: string, + sendId: string, + fileId: string, + secret: string +): Promise { + const header = { alg: 'HS256', typ: 'JWT' }; + const now = Math.floor(Date.now() / 1000); + const payload: SendFileUploadClaims = { + userId, + 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 verifySendFileUploadToken( + 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: SendFileUploadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64))); + const now = Math.floor(Date.now() / 1000); + if (payload.exp < now) return null; + if (!payload.userId || !payload.sendId || !payload.fileId) return null; + return payload; + } catch { + return null; + } +} + export interface SendAccessTokenClaims { sub: string; // send id typ: 'send_access'; diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 70e0b0f..391397c 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -913,11 +913,15 @@ export default function App() { onDownloadVaultAttachment: vaultSendActions.downloadVaultAttachment, downloadingAttachmentKey: vaultSendActions.downloadingAttachmentKey, attachmentDownloadPercent: vaultSendActions.attachmentDownloadPercent, + uploadingAttachmentName: vaultSendActions.uploadingAttachmentName, + attachmentUploadPercent: vaultSendActions.attachmentUploadPercent, onRefreshVault: vaultSendActions.refreshVault, onCreateSend: vaultSendActions.createSend, onUpdateSend: vaultSendActions.updateSend, onDeleteSend: vaultSendActions.deleteSend, onBulkDeleteSends: vaultSendActions.bulkDeleteSends, + uploadingSendFileName: vaultSendActions.uploadingSendFileName, + sendUploadPercent: vaultSendActions.sendUploadPercent, onChangePassword: accountSecurityActions.changePassword, onEnableTotp: async (secret: string, token: string) => { await accountSecurityActions.enableTotp(secret, token); diff --git a/webapp/src/components/AdminPage.tsx b/webapp/src/components/AdminPage.tsx index 01f1563..d9621ce 100644 --- a/webapp/src/components/AdminPage.tsx +++ b/webapp/src/components/AdminPage.tsx @@ -11,7 +11,7 @@ interface AdminPageProps { onRefresh: () => void; onCreateInvite: (hours: number) => Promise; onDeleteAllInvites: () => Promise; - onToggleUserStatus: (userId: string, currentStatus: string) => Promise; + onToggleUserStatus: (userId: string, currentStatus: 'active' | 'banned') => Promise; onDeleteUser: (userId: string) => Promise; onRevokeInvite: (code: string) => Promise; } diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index e3623b5..f2b52a1 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -67,11 +67,15 @@ export interface AppMainRoutesProps { onDownloadVaultAttachment: (cipher: Cipher, attachmentId: string) => Promise; downloadingAttachmentKey: string; attachmentDownloadPercent: number | null; + uploadingAttachmentName: string; + attachmentUploadPercent: number | null; onRefreshVault: () => Promise; onCreateSend: (draft: SendDraft, autoCopyLink: boolean) => Promise; onUpdateSend: (send: Send, draft: SendDraft, autoCopyLink: boolean) => Promise; onDeleteSend: (send: Send) => Promise; onBulkDeleteSends: (ids: string[]) => Promise; + uploadingSendFileName: string; + sendUploadPercent: number | null; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise; onEnableTotp: (secret: string, token: string) => Promise; onOpenDisableTotp: () => void; @@ -139,6 +143,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { onUpdate={props.onUpdateSend} onDelete={props.onDeleteSend} onBulkDelete={props.onBulkDeleteSends} + uploadingSendFileName={props.uploadingSendFileName} + sendUploadPercent={props.sendUploadPercent} onNotify={props.onNotify} /> @@ -171,6 +177,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { onDownloadAttachment={props.onDownloadVaultAttachment} downloadingAttachmentKey={props.downloadingAttachmentKey} attachmentDownloadPercent={props.attachmentDownloadPercent} + uploadingAttachmentName={props.uploadingAttachmentName} + attachmentUploadPercent={props.attachmentUploadPercent} /> diff --git a/webapp/src/components/SendsPage.tsx b/webapp/src/components/SendsPage.tsx index d38a5df..9d506d8 100644 --- a/webapp/src/components/SendsPage.tsx +++ b/webapp/src/components/SendsPage.tsx @@ -12,6 +12,8 @@ interface SendsPageProps { onUpdate: (send: Send, draft: SendDraft, autoCopyLink: boolean) => Promise; onDelete: (send: Send) => Promise; onBulkDelete: (ids: string[]) => Promise; + uploadingSendFileName: string; + sendUploadPercent: number | null; onNotify: (type: 'success' | 'error', text: string) => void; } @@ -79,6 +81,13 @@ export default function SendsPage(props: SendsPageProps) { return false; } }); + const sendUploadLabel = + props.sendUploadPercent == null + ? t('txt_uploading_file_named', { name: props.uploadingSendFileName || t('txt_file') }) + : t('txt_uploading_file_named_percent', { + name: props.uploadingSendFileName || t('txt_file'), + percent: props.sendUploadPercent, + }); useEffect(() => { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; @@ -370,6 +379,7 @@ export default function SendsPage(props: SendsPageProps) { {isEditing && draft && (

{isCreating ? t('txt_new_send') : t('txt_edit_send')}

+ {!!props.uploadingSendFileName &&
{sendUploadLabel}
}
+ {!!props.uploadingAttachmentName &&
{uploadLabel}
} {!props.isCreating && props.selectedCipher && props.editExistingAttachments.length > 0 && (
{props.editExistingAttachments.map((attachment) => { diff --git a/webapp/src/hooks/useVaultSendActions.ts b/webapp/src/hooks/useVaultSendActions.ts index 052efc6..76e8dc9 100644 --- a/webapp/src/hooks/useVaultSendActions.ts +++ b/webapp/src/hooks/useVaultSendActions.ts @@ -93,6 +93,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) } = options; const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState(''); const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState(null); + const [uploadingAttachmentName, setUploadingAttachmentName] = useState(''); + const [attachmentUploadPercent, setAttachmentUploadPercent] = useState(null); + const [uploadingSendFileName, setUploadingSendFileName] = useState(''); + const [sendUploadPercent, setSendUploadPercent] = useState(null); return useMemo(() => { const refetchVault = async () => { @@ -132,13 +136,18 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) const file = new File([fileBytes], name, { type: 'application/octet-stream' }); const cipher = cipherById.get(targetCipherId) || null; try { - await uploadCipherAttachment(importAuthedFetch, session, targetCipherId, file, cipher); + setUploadingAttachmentName(name); + setAttachmentUploadPercent(0); + await uploadCipherAttachment(importAuthedFetch, session, targetCipherId, file, cipher, setAttachmentUploadPercent); imported += 1; } catch (error) { failed.push({ fileName: name, reason: error instanceof Error ? error.message : t('txt_upload_attachment_failed'), }); + } finally { + setUploadingAttachmentName(''); + setAttachmentUploadPercent(null); } } @@ -157,13 +166,18 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { const created = await createCipher(authedFetch, session, draft); for (const file of attachments) { - await uploadCipherAttachment(authedFetch, session, created.id, file); + setUploadingAttachmentName(file.name); + setAttachmentUploadPercent(0); + await uploadCipherAttachment(authedFetch, session, created.id, file, undefined, setAttachmentUploadPercent); } await Promise.all([refetchCiphers(), refetchFolders()]); onNotify('success', t('txt_item_created')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_create_item_failed')); throw error; + } finally { + setUploadingAttachmentName(''); + setAttachmentUploadPercent(null); } }, @@ -179,13 +193,18 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) await deleteCipherAttachment(authedFetch, cipher.id, id); } for (const file of addFiles) { - await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher); + setUploadingAttachmentName(file.name); + setAttachmentUploadPercent(0); + await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher, setAttachmentUploadPercent); } await Promise.all([refetchCiphers(), refetchFolders()]); onNotify('success', t('txt_item_updated')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed')); throw error; + } finally { + setUploadingAttachmentName(''); + setAttachmentUploadPercent(null); } }, @@ -316,7 +335,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) async createSend(draft: SendDraft, autoCopyLink: boolean) { if (!session) return; try { - const created = await createSend(authedFetch, session, draft); + const fileName = draft.type === 'file' ? String(draft.file?.name || '').trim() : ''; + if (fileName) { + setUploadingSendFileName(fileName); + setSendUploadPercent(0); + } + const created = await createSend(authedFetch, session, draft, fileName ? setSendUploadPercent : undefined); await refetchSends(); if (autoCopyLink && created.key && session.symEncKey && session.symMacKey) { const keyPart = await buildSendShareKey(created.key, session.symEncKey, session.symMacKey); @@ -327,6 +351,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_create_send_failed')); throw error; + } finally { + setUploadingSendFileName(''); + setSendUploadPercent(null); } }, @@ -696,9 +723,14 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) }, downloadingAttachmentKey, attachmentDownloadPercent, + uploadingAttachmentName, + attachmentUploadPercent, + uploadingSendFileName, + sendUploadPercent, }; }, [ attachmentDownloadPercent, + attachmentUploadPercent, authedFetch, defaultKdfIterations, downloadingAttachmentKey, @@ -711,5 +743,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) refetchFolders, refetchSends, session, + sendUploadPercent, + uploadingAttachmentName, + uploadingSendFileName, ]); } diff --git a/webapp/src/lib/api/send.ts b/webapp/src/lib/api/send.ts index 6b23310..fbee87e 100644 --- a/webapp/src/lib/api/send.ts +++ b/webapp/src/lib/api/send.ts @@ -1,6 +1,6 @@ import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, pbkdf2 } from '../crypto'; import type { Send, SendDraft, SessionState } from '../types'; -import { chunkArray, createApiError, parseErrorMessage, parseJson, type AuthedFetch } from './shared'; +import { chunkArray, createApiError, parseErrorMessage, parseJson, uploadDirectEncryptedPayload, type AuthedFetch } from './shared'; function toIsoDateFromDays(value: string, required: boolean): string | null { const raw = String(value || '').trim(); @@ -70,7 +70,8 @@ export async function getSends(authedFetch: AuthedFetch): Promise { export async function createSend( authedFetch: AuthedFetch, session: SessionState, - draft: SendDraft + draft: SendDraft, + onProgress?: (percent: number | null) => void ): Promise { if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable'); const userEnc = base64ToBytes(session.symEncKey); @@ -148,16 +149,16 @@ export async function createSend( }); if (!fileResp.ok) throw new Error(await parseErrorMessage(fileResp, 'Create file send failed')); - const uploadInfo = await parseJson<{ url?: string; sendResponse?: Send }>(fileResp); + const uploadInfo = await parseJson<{ url?: string; sendResponse?: Send; fileUploadType?: number }>(fileResp); const uploadUrl = uploadInfo?.url; if (!uploadUrl) throw new Error('Create file send failed: missing upload URL'); - - const formData = new FormData(); - const encryptedBlob = new Blob([encryptedFileBytes as unknown as BlobPart], { type: 'application/octet-stream' }); - formData.set('data', encryptedBlob, fileNameCipher); - const uploadResp = await authedFetch(uploadUrl, { - method: 'POST', - body: formData, + const uploadResp = await uploadDirectEncryptedPayload({ + accessToken: session.accessToken, + uploadUrl, + payload: encryptedFileBytes, + fileUploadType: uploadInfo?.fileUploadType, + unsupportedMessage: 'Unsupported send upload type', + onProgress, }); if (!uploadResp.ok) throw new Error(await parseErrorMessage(uploadResp, 'Upload send file failed')); if (!uploadInfo?.sendResponse?.id) throw new Error('Create file send failed'); diff --git a/webapp/src/lib/api/shared.ts b/webapp/src/lib/api/shared.ts index 94f9050..445a3e4 100644 --- a/webapp/src/lib/api/shared.ts +++ b/webapp/src/lib/api/shared.ts @@ -58,3 +58,99 @@ export function createApiError(message: string, status?: number): Error & { stat export function requiredError(messageKey: string): never { throw new Error(t(messageKey)); } + +interface UploadWithProgressOptions { + accessToken?: string; + method?: string; + headers?: HeadersInit; + body?: Document | XMLHttpRequestBodyInit | null; + onProgress?: (percent: number | null) => void; +} + +interface DirectEncryptedUploadOptions { + accessToken: string; + uploadUrl: string; + payload: ArrayBuffer | Uint8Array; + fileUploadType: number | null | undefined; + unsupportedMessage: string; + onProgress?: (percent: number | null) => void; +} + +function toAbsoluteUrl(input: string): string { + if (typeof window === 'undefined') return input; + return new URL(input, window.location.origin).toString(); +} + +function parseXhrHeaders(raw: string): Headers { + const headers = new Headers(); + for (const line of raw.split(/\r?\n/)) { + const index = line.indexOf(':'); + if (index <= 0) continue; + const name = line.slice(0, index).trim(); + const value = line.slice(index + 1).trim(); + if (name) headers.append(name, value); + } + return headers; +} + +export async function uploadWithProgress(input: string, options: UploadWithProgressOptions = {}): Promise { + if (typeof XMLHttpRequest === 'undefined') { + const headers = new Headers(options.headers || {}); + if (options.accessToken) headers.set('Authorization', `Bearer ${options.accessToken}`); + return fetch(input, { + method: options.method || 'POST', + headers, + body: options.body ?? null, + }); + } + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open(options.method || 'POST', toAbsoluteUrl(input), true); + + const headers = new Headers(options.headers || {}); + if (options.accessToken) headers.set('Authorization', `Bearer ${options.accessToken}`); + headers.forEach((value, key) => xhr.setRequestHeader(key, value)); + + xhr.upload.onprogress = (event) => { + if (!options.onProgress) return; + if (!event.lengthComputable || event.total <= 0) { + options.onProgress(null); + return; + } + options.onProgress(Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100)))); + }; + + xhr.onerror = () => reject(new Error('Network error')); + xhr.onabort = () => reject(new Error('Upload aborted')); + xhr.onload = () => { + options.onProgress?.(100); + resolve( + new Response(xhr.responseText || null, { + status: xhr.status, + statusText: xhr.statusText, + headers: parseXhrHeaders(xhr.getAllResponseHeaders()), + }) + ); + }; + + xhr.send(options.body ?? null); + }); +} + +export async function uploadDirectEncryptedPayload(options: DirectEncryptedUploadOptions): Promise { + if (options.fileUploadType !== 1) { + throw new Error(options.unsupportedMessage); + } + + return uploadWithProgress(options.uploadUrl, { + accessToken: options.accessToken, + method: 'PUT', + headers: { + 'Content-Type': 'application/octet-stream', + 'x-ms-blob-type': 'BlockBlob', + }, + body: options.payload, + onProgress: options.onProgress, + }); +} diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index 97a779c..2630354 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -12,6 +12,7 @@ import { chunkArray, parseErrorMessage, parseJson, + uploadDirectEncryptedPayload, type AuthedFetch, } from './shared'; import { readResponseBytesWithProgress } from '../download'; @@ -199,7 +200,8 @@ export async function uploadCipherAttachment( session: SessionState, cipherId: string, file: File, - cipherForKey?: Cipher | null + cipherForKey?: Cipher | null, + onProgress?: (percent: number | null) => void ): Promise { if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable'); const id = String(cipherId || '').trim(); @@ -233,6 +235,7 @@ export async function uploadCipherAttachment( (await parseJson<{ attachmentId?: string; url?: string; + fileUploadType?: number; }>(metaResp)) || {}; const attachmentId = String(meta.attachmentId || '').trim(); const uploadUrl = String(meta.url || '').trim(); @@ -240,12 +243,13 @@ export async function uploadCipherAttachment( const payload = new ArrayBuffer(encryptedBytes.byteLength); new Uint8Array(payload).set(encryptedBytes); - const formData = new FormData(); - formData.set('data', new Blob([payload], { type: 'application/octet-stream' }), encryptedFileName); - - const uploadResp = await authedFetch(uploadUrl, { - method: 'POST', - body: formData, + const uploadResp = await uploadDirectEncryptedPayload({ + accessToken: session.accessToken, + uploadUrl, + payload, + fileUploadType: meta.fileUploadType, + unsupportedMessage: 'Unsupported attachment upload type', + onProgress, }); if (!uploadResp.ok) { try { diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index 845ba50..b77574b 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -291,10 +291,15 @@ const messages: Record> = { txt_disable_this_send: "Disable this send", txt_disable_totp: "Disable TOTP", txt_disable_totp_failed: "Disable TOTP failed", - txt_download: "Download", - txt_downloading: "Downloading...", - txt_downloading_percent: "Downloading {percent}%", - txt_download_failed: "Download failed", + txt_download: "Download", + txt_downloading: "Downloading...", + txt_downloading_percent: "Downloading {percent}%", + txt_attachment: "Attachment", + txt_uploading_attachment_named: "Uploading {name}...", + txt_uploading_attachment_named_percent: "Uploading {name} {percent}%", + txt_uploading_file_named: "Uploading {name}...", + txt_uploading_file_named_percent: "Uploading {name} {percent}%", + txt_download_failed: "Download failed", txt_edge_browser: "Edge Browser", txt_edge_extension: "Edge Extension", txt_edit: "Edit", @@ -928,6 +933,11 @@ const zhCNOverrides: Record = { txt_download: '下载', txt_downloading: '下载中...', txt_downloading_percent: '下载中 {percent}%', + txt_attachment: '附件', + txt_uploading_attachment_named: '正在上传 {name}...', + txt_uploading_attachment_named_percent: '正在上传 {name} {percent}%', + txt_uploading_file_named: '正在上传 {name}...', + txt_uploading_file_named_percent: '正在上传 {name} {percent}%', txt_expires_at: '过期时间', txt_expires_at_value: '过期于:{value}', txt_dash: '-', @@ -1192,6 +1202,11 @@ zhCNOverrides.txt_passkey_created_at_value = '创建于 {value}'; zhCNOverrides.txt_attachments = '附件'; zhCNOverrides.txt_upload_attachments = '上传附件'; zhCNOverrides.txt_new_attachments = '待上传附件'; +zhCNOverrides.txt_attachment = '附件'; +zhCNOverrides.txt_uploading_attachment_named = '正在上传 {name}...'; +zhCNOverrides.txt_uploading_attachment_named_percent = '正在上传 {name} {percent}%'; +zhCNOverrides.txt_uploading_file_named = '正在上传 {name}...'; +zhCNOverrides.txt_uploading_file_named_percent = '正在上传 {name} {percent}%'; zhCNOverrides.txt_marked_for_removal_count = '保存后将删除 {count} 个附件'; messages.en.txt_import = 'Import'; messages.en.txt_export = 'Export';