feat: add support for KV storage mode and enhance attachment handling

This commit is contained in:
shuaiplus
2026-03-06 01:00:19 +08:00
parent c54740517c
commit 57aa7457ae
11 changed files with 289 additions and 71 deletions
+1 -1
View File
@@ -82,7 +82,7 @@
send: {
// Max file size allowed for Send file uploads.
// Send 文件上传大小上限。
maxFileSizeBytes: 550_502_400,
maxFileSizeBytes: 100 * 1024 * 1024,
// Max days allowed between now and deletion date.
// 允许的最远删除日期(距当前天数)。
maxDeletionDays: 31,
+3 -2
View File
@@ -2,6 +2,7 @@ import { Env, User, Invite } from '../types';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { deleteBlobObject, getAttachmentObjectKey, getSendFileObjectKey } from '../services/blob-store';
function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active';
@@ -260,7 +261,7 @@ export async function handleAdminDeleteUser(
const attachmentMap = await storage.getAttachmentsByUserId(target.id);
for (const [cipherId, attachments] of attachmentMap) {
for (const att of attachments) {
await env.ATTACHMENTS.delete(`${cipherId}/${att.id}`);
await deleteBlobObject(env, getAttachmentObjectKey(cipherId, att.id));
}
}
// 2. Send files (keyed by sends/sendId/fileId)
@@ -271,7 +272,7 @@ export async function handleAdminDeleteUser(
const parsed = JSON.parse(send.data) as Record<string, unknown>;
const fileId = typeof parsed.id === 'string' ? parsed.id : null;
if (fileId) {
await env.ATTACHMENTS.delete(`sends/${send.id}/${fileId}`);
await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId));
}
} catch { /* non-file send or bad data, skip */ }
}
+35 -31
View File
@@ -5,6 +5,13 @@ import { generateUUID } from '../utils/uuid';
import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
import { cipherToResponse } from './ciphers';
import { LIMITS } from '../config/limits';
import {
deleteBlobObject,
getAttachmentObjectKey,
getBlobObject,
getBlobStorageMaxBytes,
putBlobObject,
} from '../services/blob-store';
// Format file size to human readable
function formatSize(bytes: number): string {
@@ -14,11 +21,6 @@ function formatSize(bytes: number): string {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
// Get R2 object path for attachment
function getAttachmentPath(cipherId: string, attachmentId: string): string {
return `${cipherId}/${attachmentId}`;
}
// POST /api/ciphers/{cipherId}/attachment/v2
// Creates attachment metadata and returns upload URL
export async function handleCreateAttachment(
@@ -86,9 +88,6 @@ export async function handleCreateAttachment(
});
}
// Maximum file size: 100MB
const MAX_FILE_SIZE = LIMITS.attachment.maxFileSizeBytes;
// POST /api/ciphers/{cipherId}/attachment/{attachmentId}
// Upload attachment file content
export async function handleUploadAttachment(
@@ -99,6 +98,7 @@ export async function handleUploadAttachment(
attachmentId: string
): Promise<Response> {
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);
@@ -114,8 +114,8 @@ export async function handleUploadAttachment(
// Check content-length header for size limit
const contentLength = request.headers.get('content-length');
if (contentLength && parseInt(contentLength) > MAX_FILE_SIZE) {
return errorResponse('File too large. Maximum size is 100MB', 413);
if (contentLength && parseInt(contentLength) > maxFileSize) {
return errorResponse(`File too large. Maximum size is ${Math.floor(maxFileSize / (1024 * 1024))}MB`, 413);
}
// Get the file from multipart form data
@@ -132,21 +132,27 @@ export async function handleUploadAttachment(
}
// Check actual file size
if (file.size > MAX_FILE_SIZE) {
return errorResponse('File too large. Maximum size is 100MB', 413);
if (file.size > maxFileSize) {
return errorResponse(`File too large. Maximum size is ${Math.floor(maxFileSize / (1024 * 1024))}MB`, 413);
}
// Store file in R2
const path = getAttachmentPath(cipherId, attachmentId);
await env.ATTACHMENTS.put(path, file.stream(), {
httpMetadata: {
const path = getAttachmentObjectKey(cipherId, attachmentId);
try {
await putBlobObject(env, path, file.stream(), {
size: file.size,
contentType: 'application/octet-stream',
},
customMetadata: {
cipherId: cipherId,
attachmentId: attachmentId,
},
});
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);
}
// Update attachment size if different
const actualSize = file.size;
@@ -242,9 +248,8 @@ export async function handlePublicDownloadAttachment(
return errorResponse('Attachment not found', 404);
}
// Get file from R2
const path = getAttachmentPath(cipherId, attachmentId);
const object = await env.ATTACHMENTS.get(path);
const path = getAttachmentObjectKey(cipherId, attachmentId);
const object = await getBlobObject(env, path);
if (!object) {
return errorResponse('Attachment file not found', 404);
@@ -257,7 +262,7 @@ export async function handlePublicDownloadAttachment(
return new Response(object.body, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Type': object.contentType || 'application/octet-stream',
'Content-Length': String(object.size),
'Cache-Control': 'private, no-cache',
},
@@ -287,9 +292,8 @@ export async function handleDeleteAttachment(
return errorResponse('Attachment not found', 404);
}
// Delete file from R2
const path = getAttachmentPath(cipherId, attachmentId);
await env.ATTACHMENTS.delete(path);
const path = getAttachmentObjectKey(cipherId, attachmentId);
await deleteBlobObject(env, path);
// Delete attachment metadata
await storage.deleteAttachment(attachmentId);
@@ -318,8 +322,8 @@ export async function deleteAllAttachmentsForCipher(
const attachments = await storage.getAttachmentsByCipher(cipherId);
for (const attachment of attachments) {
const path = getAttachmentPath(cipherId, attachment.id);
await env.ATTACHMENTS.delete(path);
const path = getAttachmentObjectKey(cipherId, attachment.id);
await deleteBlobObject(env, path);
await storage.deleteAttachment(attachment.id);
}
}
+29 -17
View File
@@ -11,6 +11,13 @@ import {
verifySendAccessToken,
verifySendFileDownloadToken,
} from '../utils/jwt';
import {
deleteBlobObject,
getBlobObject,
getBlobStorageMaxBytes,
getSendFileObjectKey,
putBlobObject,
} from '../services/blob-store';
const SEND_INACCESSIBLE_MSG = 'Send does not exist or is no longer available';
const SEND_PASSWORD_ITERATIONS = 100_000;
@@ -142,10 +149,6 @@ function normalizeSendDataSizeField(data: Record<string, unknown>): Record<strin
return normalized;
}
function getSendFilePath(sendId: string, fileId: string): string {
return `sends/${sendId}/${fileId}`;
}
export function isSendAvailable(send: Send): boolean {
const now = Date.now();
@@ -609,6 +612,7 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin
// POST /api/sends/file/v2
export async function handleCreateFileSendV2(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes);
let body: unknown;
try {
@@ -626,7 +630,7 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId:
const fileLengthRaw = getAliasedProp(body, ['fileLength', 'FileLength']);
const fileLengthParsed = parseFileLength(fileLengthRaw.value);
if (!fileLengthParsed.ok) return fileLengthParsed.response;
if (fileLengthParsed.value > LIMITS.send.maxFileSizeBytes) {
if (fileLengthParsed.value > maxFileSize) {
return errorResponse('Send storage limit exceeded with this file', 400);
}
@@ -774,6 +778,7 @@ export async function handleUploadSendFile(
fileId: string
): Promise<Response> {
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);
@@ -799,7 +804,7 @@ export async function handleUploadSendFile(
return errorResponse('No file uploaded', 400);
}
if (file.size > LIMITS.send.maxFileSizeBytes) {
if (file.size > maxFileSize) {
return errorResponse('Send storage limit exceeded with this file', 413);
}
@@ -813,15 +818,22 @@ export async function handleUploadSendFile(
return errorResponse('Send file size does not match.', 400);
}
await env.ATTACHMENTS.put(getSendFilePath(sendId, fileId), file.stream(), {
httpMetadata: {
try {
await putBlobObject(env, getSendFileObjectKey(sendId, fileId), file.stream(), {
size: file.size,
contentType: 'application/octet-stream',
},
customMetadata: {
sendId,
fileId,
},
});
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);
}
await storage.updateRevisionDate(userId);
@@ -987,7 +999,7 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin
const data = parseStoredSendData(send);
const fileId = typeof data.id === 'string' ? data.id : null;
if (fileId) {
await env.ATTACHMENTS.delete(getSendFilePath(send.id, fileId));
await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId));
}
}
@@ -1282,7 +1294,7 @@ export async function handleDownloadSendFile(
}
const storage = new StorageService(env.DB);
const object = await env.ATTACHMENTS.get(getSendFilePath(sendId, fileId));
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
if (!object) {
return errorResponse('Send file not found', 404);
}
@@ -1296,7 +1308,7 @@ export async function handleDownloadSendFile(
return new Response(object.body, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Type': object.contentType || 'application/octet-stream',
'Content-Length': String(object.size),
'Cache-Control': 'private, no-cache',
},
+124
View File
@@ -0,0 +1,124 @@
import { Env } from '../types';
const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
export const KV_MAX_OBJECT_BYTES = 25 * 1024 * 1024;
interface KVBlobMetadata {
size?: number;
contentType?: string;
customMetadata?: Record<string, string> | null;
}
export interface BlobObject {
body: ReadableStream | null;
size: number;
contentType: string;
}
export interface PutBlobOptions {
size: number;
contentType?: string;
customMetadata?: Record<string, string>;
}
function hasR2Storage(env: Env): env is Env & { ATTACHMENTS: R2Bucket } {
return !!env.ATTACHMENTS;
}
function hasKvStorage(env: Env): env is Env & { ATTACHMENTS_KV: KVNamespace } {
return !!env.ATTACHMENTS_KV;
}
export function getBlobStorageKind(env: Env): 'r2' | 'kv' | null {
// Keep R2 as preferred backend when both are bound.
if (hasR2Storage(env)) return 'r2';
if (hasKvStorage(env)) return 'kv';
return null;
}
export function getBlobStorageMaxBytes(env: Env, configuredLimit: number): number {
if (getBlobStorageKind(env) === 'kv') {
return Math.min(configuredLimit, KV_MAX_OBJECT_BYTES);
}
return configuredLimit;
}
export function getAttachmentObjectKey(cipherId: string, attachmentId: string): string {
return `${cipherId}/${attachmentId}`;
}
export function getSendFileObjectKey(sendId: string, fileId: string): string {
return `sends/${sendId}/${fileId}`;
}
export async function putBlobObject(
env: Env,
key: string,
value: string | ArrayBuffer | ArrayBufferView | ReadableStream,
options: PutBlobOptions
): Promise<void> {
const contentType = options.contentType || DEFAULT_CONTENT_TYPE;
if (hasR2Storage(env)) {
await env.ATTACHMENTS.put(key, value, {
httpMetadata: { contentType },
customMetadata: options.customMetadata,
});
return;
}
if (hasKvStorage(env)) {
if (options.size > KV_MAX_OBJECT_BYTES) {
throw new Error('KV object too large');
}
const metadata: KVBlobMetadata = {
size: options.size,
contentType,
customMetadata: options.customMetadata || null,
};
await env.ATTACHMENTS_KV.put(key, value, { metadata });
return;
}
throw new Error('Attachment storage is not configured');
}
export async function getBlobObject(env: Env, key: string): Promise<BlobObject | null> {
if (hasR2Storage(env)) {
const object = await env.ATTACHMENTS.get(key);
if (!object) return null;
return {
body: object.body,
size: Number(object.size) || 0,
contentType: object.httpMetadata?.contentType || DEFAULT_CONTENT_TYPE,
};
}
if (hasKvStorage(env)) {
const result = await env.ATTACHMENTS_KV.getWithMetadata<KVBlobMetadata>(key, 'arrayBuffer');
if (!result.value) return null;
const sizeFromMeta = Number(result.metadata?.size || 0);
const size = sizeFromMeta > 0 ? sizeFromMeta : result.value.byteLength;
const body = new Response(result.value).body;
return {
body,
size,
contentType: result.metadata?.contentType || DEFAULT_CONTENT_TYPE,
};
}
return null;
}
export async function deleteBlobObject(env: Env, key: string): Promise<void> {
if (hasR2Storage(env)) {
await env.ATTACHMENTS.delete(key);
return;
}
if (hasKvStorage(env)) {
await env.ATTACHMENTS_KV.delete(key);
return;
}
}
+28
View File
@@ -299,6 +299,29 @@ function normalizeClientIpForRateLimit(rawIp: string): string | null {
return `ip6:${prefix64}`;
}
function isLocalRequest(request: Request): boolean {
const isLoopbackHost = (host: string | null): boolean => {
if (!host) return false;
const normalized = host.split(':')[0].trim().toLowerCase();
return (
normalized === 'localhost' ||
normalized.endsWith('.localhost') ||
normalized === '127.0.0.1' ||
normalized === '0.0.0.0' ||
normalized === '::1' ||
normalized === '[::1]'
);
};
try {
if (isLoopbackHost(new URL(request.url).hostname)) return true;
} catch {
// Ignore malformed URL and fall back to Host header check.
}
return isLoopbackHost(request.headers.get('Host'));
}
export function getClientIdentifier(request: Request): string | null {
// Strict fallback order:
// 1) CF-Connecting-IP
@@ -317,5 +340,10 @@ export function getClientIdentifier(request: Request): string | null {
if (normalized) return normalized;
}
// Local dev (wrangler dev / localhost): allow a deterministic loopback identifier.
if (isLocalRequest(request)) {
return 'ip4:127.0.0.1';
}
return null;
}
+4 -1
View File
@@ -1,7 +1,10 @@
// Environment bindings
export interface Env {
DB: D1Database;
ATTACHMENTS: R2Bucket;
// Prefer R2 when available. Optional to support KV-only deployments.
ATTACHMENTS?: R2Bucket;
// Optional fallback for attachment/send file storage (no credit card required).
ATTACHMENTS_KV?: KVNamespace;
JWT_SECRET: string;
TOTP_SECRET?: string;
}