mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-21 05:10:41 +00:00
Basic success
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
import { Env, Attachment, Cipher } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
|
||||
|
||||
// Format file size to human readable
|
||||
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`;
|
||||
}
|
||||
|
||||
// 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(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
cipherId: string
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
let body: {
|
||||
fileName?: string;
|
||||
key?: string;
|
||||
fileSize?: number;
|
||||
};
|
||||
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
if (!body.fileName || !body.key) {
|
||||
return errorResponse('fileName and key are required', 400);
|
||||
}
|
||||
|
||||
const fileSize = body.fileSize || 0;
|
||||
const attachmentId = generateUUID();
|
||||
|
||||
// Create attachment metadata
|
||||
const attachment: Attachment = {
|
||||
id: attachmentId,
|
||||
cipherId: cipherId,
|
||||
fileName: body.fileName,
|
||||
size: fileSize,
|
||||
sizeName: formatSize(fileSize),
|
||||
key: body.key,
|
||||
};
|
||||
|
||||
// Save attachment metadata
|
||||
await storage.saveAttachment(attachment);
|
||||
|
||||
// Add attachment to cipher
|
||||
await storage.addAttachmentToCipher(cipherId, attachmentId);
|
||||
|
||||
// Update cipher revision date
|
||||
await storage.updateCipherRevisionDate(cipherId);
|
||||
|
||||
// Get updated cipher for response
|
||||
const updatedCipher = await storage.getCipher(cipherId);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
|
||||
return jsonResponse({
|
||||
object: 'attachment-fileUpload',
|
||||
attachmentId: attachmentId,
|
||||
url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`,
|
||||
fileUploadType: 0, // Direct upload
|
||||
cipherResponse: formatCipherResponse(updatedCipher!, attachments),
|
||||
});
|
||||
}
|
||||
|
||||
// Maximum file size: 100MB
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
||||
|
||||
// POST /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||
// Upload attachment file content
|
||||
export async function handleUploadAttachment(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
cipherId: string,
|
||||
attachmentId: string
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
// Verify attachment exists
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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 formData = await request.formData();
|
||||
const file = formData.get('data') as File | null;
|
||||
|
||||
if (!file) {
|
||||
return errorResponse('No file uploaded', 400);
|
||||
}
|
||||
|
||||
// Check actual file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return errorResponse('File too large. Maximum size is 100MB', 413);
|
||||
}
|
||||
|
||||
// Store file in R2
|
||||
const path = getAttachmentPath(cipherId, attachmentId);
|
||||
await env.ATTACHMENTS.put(path, file.stream(), {
|
||||
httpMetadata: {
|
||||
contentType: 'application/octet-stream',
|
||||
},
|
||||
customMetadata: {
|
||||
cipherId: cipherId,
|
||||
attachmentId: attachmentId,
|
||||
},
|
||||
});
|
||||
|
||||
// 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
|
||||
await storage.updateCipherRevisionDate(cipherId);
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
// GET /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||
// Get attachment download info
|
||||
export async function handleGetAttachment(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
cipherId: string,
|
||||
attachmentId: string
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
// Verify attachment exists
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
|
||||
// Generate short-lived download token
|
||||
const token = await createFileDownloadToken(cipherId, attachmentId, env.JWT_SECRET);
|
||||
|
||||
// Generate download URL with token
|
||||
const url = new URL(request.url);
|
||||
const downloadUrl = `${url.origin}/api/attachments/${cipherId}/${attachmentId}?token=${token}`;
|
||||
|
||||
return jsonResponse({
|
||||
object: 'attachment',
|
||||
id: attachment.id,
|
||||
url: downloadUrl,
|
||||
fileName: attachment.fileName,
|
||||
key: attachment.key,
|
||||
size: String(attachment.size),
|
||||
sizeName: attachment.sizeName,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/attachments/{cipherId}/{attachmentId}?token=xxx
|
||||
// Public download endpoint (uses token for auth instead of header)
|
||||
export async function handlePublicDownloadAttachment(
|
||||
request: Request,
|
||||
env: Env,
|
||||
cipherId: string,
|
||||
attachmentId: string
|
||||
): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
return errorResponse('Token required', 401);
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const claims = await verifyFileDownloadToken(token, env.JWT_SECRET);
|
||||
if (!claims) {
|
||||
return errorResponse('Invalid or expired token', 401);
|
||||
}
|
||||
|
||||
// Verify token matches request
|
||||
if (claims.cipherId !== cipherId || claims.attachmentId !== attachmentId) {
|
||||
return errorResponse('Token mismatch', 401);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.VAULT);
|
||||
|
||||
// Verify attachment exists
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
|
||||
// Get file from R2
|
||||
const path = getAttachmentPath(cipherId, attachmentId);
|
||||
const object = await env.ATTACHMENTS.get(path);
|
||||
|
||||
if (!object) {
|
||||
return errorResponse('Attachment 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',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||
// Delete attachment
|
||||
export async function handleDeleteAttachment(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
cipherId: string,
|
||||
attachmentId: string
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
// Verify attachment exists
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
|
||||
// Delete file from R2
|
||||
const path = getAttachmentPath(cipherId, attachmentId);
|
||||
await env.ATTACHMENTS.delete(path);
|
||||
|
||||
// Delete attachment metadata
|
||||
await storage.deleteAttachment(attachmentId);
|
||||
|
||||
// Remove attachment from cipher
|
||||
await storage.removeAttachmentFromCipher(cipherId, attachmentId);
|
||||
|
||||
// Update cipher revision date
|
||||
await storage.updateCipherRevisionDate(cipherId);
|
||||
|
||||
// Get updated cipher for response
|
||||
const updatedCipher = await storage.getCipher(cipherId);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
|
||||
return jsonResponse({
|
||||
cipher: formatCipherResponse(updatedCipher!, attachments),
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: Format cipher response with attachments
|
||||
function formatCipherResponse(cipher: Cipher, attachments: Attachment[]): any {
|
||||
return {
|
||||
id: cipher.id,
|
||||
organizationId: null,
|
||||
folderId: cipher.folderId,
|
||||
type: cipher.type,
|
||||
name: cipher.name,
|
||||
notes: cipher.notes,
|
||||
favorite: cipher.favorite,
|
||||
login: cipher.login,
|
||||
card: cipher.card,
|
||||
identity: cipher.identity,
|
||||
secureNote: cipher.secureNote,
|
||||
fields: cipher.fields,
|
||||
passwordHistory: cipher.passwordHistory,
|
||||
reprompt: cipher.reprompt,
|
||||
organizationUseTotp: false,
|
||||
creationDate: cipher.createdAt,
|
||||
revisionDate: cipher.updatedAt,
|
||||
deletedDate: cipher.deletedAt,
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
permissions: null,
|
||||
object: 'cipher',
|
||||
collectionIds: [],
|
||||
attachments: attachments.length > 0 ? attachments.map(a => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
size: String(a.size),
|
||||
sizeName: a.sizeName,
|
||||
key: a.key,
|
||||
object: 'attachment',
|
||||
})) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Delete all attachments for a cipher (used when deleting cipher)
|
||||
export async function deleteAllAttachmentsForCipher(
|
||||
env: Env,
|
||||
cipherId: string
|
||||
): Promise<void> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const path = getAttachmentPath(cipherId, attachment.id);
|
||||
await env.ATTACHMENTS.delete(path);
|
||||
await storage.deleteAttachment(attachment.id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user