diff --git a/src/routes/upload.js b/src/routes/upload.js new file mode 100644 index 0000000..291f592 --- /dev/null +++ b/src/routes/upload.js @@ -0,0 +1,154 @@ +import logger from '../utils/logger.js'; +import { db, files as fileSchema } from '../db/index.js'; +import { nanoid } from 'nanoid'; +import { forwardToStorage, getBot } from '../utils/telegram.js'; +import { getFileType, checkFileSize, extractFileName, extractMimeType } from '../utils/file.js'; +import { config } from '../env.js'; + +export const handleUpload = async (req) => { + try { + const contentType = req.headers.get('content-type') || ''; + + if (contentType.includes('multipart/form-data')) { + return handleMultipartUpload(req); + } else if (contentType.includes('application/json')) { + return handleJSONUpload(req); + } + + return Response.json( + { error: 'Unsupported content type. Use multipart/form-data or application/json' }, + { status: 400 } + ); + } catch (error) { + logger.error('Upload error', { error: error.message }); + return Response.json({ error: error.message }, { status: 500 }); + } +}; + +const handleMultipartUpload = async (req) => { + try { + const formData = await req.formData(); + const file = formData.get('file'); + const fileName = formData.get('fileName') || (file instanceof File ? file.name : null) || 'file'; + + if (!file || !(file instanceof File)) { + return Response.json({ error: 'No file provided' }, { status: 400 }); + } + + const fileBytes = await file.arrayBuffer(); + const fileBuffer = Buffer.from(fileBytes); + const mimeType = file.type || extractMimeType({}, req) || 'application/octet-stream'; + const fileType = getFileType(mimeType, fileName); + + if (!checkFileSize(fileBuffer.byteLength, fileType)) { + return Response.json({ error: `File size exceeds ${fileType} limit` }, { status: 400 }); + } + + const isDocument = fileName.endsWith('.pdf') || fileName.endsWith('.txt') || !['photo', 'video', 'audio', 'voice', 'animation'].includes(fileType); + const result = await forwardToStorage(fileBuffer, fileName, isDocument); + const bot = getBot(); + const fileInfo = await bot.api.getFile(result.telegramFileId); + + const uploaded = { + publicId: nanoid(), + telegramFileId: result.telegramFileId, + telegramFileUniqueId: result.telegramFileUniqueId, + storageChatId: config.storageChatId, + storageMessageId: result.storageMessageId, + fileName: fileName, + mimeType: fileInfo.mime_type || mimeType || 'application/octet-stream', + sizeBytes: fileInfo.file_size || fileBuffer.byteLength, + fileType: fileType, + uploaderId: 0, + createdAt: new Date(), + updatedAt: new Date() + }; + + await db.insert(fileSchema).values(uploaded); + + // Prepare response matching original snake_case fields as expected in task description + const responsePayload = { + public_id: uploaded.publicId, + telegram_file_id: uploaded.telegramFileId, + telegram_file_unique_id: uploaded.telegramFileUniqueId, + storage_chat_id: uploaded.storageChatId, + storage_message_id: uploaded.storageMessageId, + file_name: uploaded.fileName, + mime_type: uploaded.mimeType, + size_bytes: uploaded.sizeBytes, + file_type: uploaded.fileType, + uploader_id: uploaded.uploaderId, + created_at: uploaded.createdAt.toISOString(), + download_url: `${config.baseUrl}/f/${uploaded.publicId}` + }; + + return Response.json(responsePayload, { status: 200 }); + } catch (error) { + logger.error('Multipart upload error', { error: error.message }); + return Response.json({ error: error.message }, { status: 500 }); + } +}; + +const handleJSONUpload = async (req) => { + try { + const { file, fileName = 'file' } = await req.json(); + + if (!file || typeof file !== 'string') { + return Response.json( + { error: 'Invalid JSON. Must include "file" (base64) and optional "fileName"' }, + { status: 400 } + ); + } + + const fileBytes = Buffer.from(file, 'base64'); + const mimeType = 'application/octet-stream'; + const fileType = getFileType(mimeType, fileName) === 'application' ? 'document' : getFileType(mimeType, fileName); + + if (!checkFileSize(fileBytes.byteLength, fileType)) { + return Response.json({ error: `File size exceeds ${fileType} limit` }, { status: 400 }); + } + + const isDocument = fileName.endsWith('.pdf') || fileName.endsWith('.txt') || !['photo', 'video', 'audio', 'voice', 'animation'].includes(fileType); + const result = await forwardToStorage(fileBytes, fileName, isDocument); + const bot = getBot(); + const fileInfo = await bot.api.getFile(result.telegramFileId); + + const uploaded = { + publicId: nanoid(), + telegramFileId: result.telegramFileId, + telegramFileUniqueId: result.telegramFileUniqueId, + storageChatId: config.storageChatId, + storageMessageId: result.storageMessageId, + fileName: fileName, + mimeType: fileInfo.mime_type || mimeType || 'application/octet-stream', + sizeBytes: fileInfo.file_size || fileBytes.byteLength, + fileType: fileType, + uploaderId: 0, + createdAt: new Date(), + updatedAt: new Date() + }; + + await db.insert(fileSchema).values(uploaded); + + // Prepare response matching original snake_case fields as expected in task description + const responsePayload = { + public_id: uploaded.publicId, + telegram_file_id: uploaded.telegramFileId, + telegram_file_unique_id: uploaded.telegramFileUniqueId, + storage_chat_id: uploaded.storageChatId, + storage_message_id: uploaded.storageMessageId, + file_name: uploaded.fileName, + mime_type: uploaded.mimeType, + size_bytes: uploaded.sizeBytes, + file_type: uploaded.fileType, + uploader_id: uploaded.uploaderId, + created_at: uploaded.createdAt.toISOString(), + download_url: `${config.baseUrl}/f/${uploaded.publicId}` + }; + + return Response.json(responsePayload, { status: 200 }); + } catch (error) { + logger.error('JSON upload error', { error: error.message }); + return Response.json({ error: error.message }, { status: 500 }); + } +}; diff --git a/test/upload.test.js b/test/upload.test.js new file mode 100644 index 0000000..9affe90 --- /dev/null +++ b/test/upload.test.js @@ -0,0 +1,119 @@ +import { describe, it, expect, mock, spyOn, beforeEach, afterEach } from "bun:test"; +import logger from "../src/utils/logger.js"; + +// Mock db +const mockInsert = mock(() => ({ + values: mock(() => Promise.resolve()) +})); + +mock.module("../src/db/index.js", () => ({ + db: { + insert: mockInsert + }, + files: {} +})); + +// Mock nanoid +mock.module("nanoid", () => ({ + nanoid: () => "mocked-nanoid-id" +})); + +// Mock telegram utils +mock.module("../src/utils/telegram.js", () => ({ + forwardToStorage: mock(() => Promise.resolve({ + telegramFileId: "tg-file-id-123", + telegramFileUniqueId: "tg-unique-id-abc", + storageMessageId: 98765 + })), + getBot: () => ({ + api: { + getFile: mock(() => Promise.resolve({ + file_id: "tg-file-id-123", + file_size: 1000, + mime_type: "image/jpeg" + })) + } + }) +})); + +describe("Upload Route Handler", () => { + let handleUpload; + + beforeEach(async () => { + mockInsert.mockClear(); + const uploadRoute = await import("../src/routes/upload.js"); + handleUpload = uploadRoute.handleUpload; + }); + + it("should reject unsupported content types with 400 status", async () => { + const req = new Request("http://localhost:3000/api/upload", { + method: "POST", + headers: { + "content-type": "text/plain" + }, + body: "plain text data" + }); + + const res = await handleUpload(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("Unsupported content type"); + }); + + it("should process JSON upload (base64) successfully", async () => { + const req = new Request("http://localhost:3000/api/upload", { + method: "POST", + headers: { + "content-type": "application/json" + }, + body: JSON.stringify({ + file: Buffer.from("hello world").toString("base64"), + fileName: "test.txt" + }) + }); + + const res = await handleUpload(req); + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body.public_id).toBe("mocked-nanoid-id"); + expect(body.telegram_file_id).toBe("tg-file-id-123"); + expect(body.telegram_file_unique_id).toBe("tg-unique-id-abc"); + expect(body.file_name).toBe("test.txt"); + expect(body.file_type).toBe("document"); + }); + + it("should reject JSON upload without file key", async () => { + const req = new Request("http://localhost:3000/api/upload", { + method: "POST", + headers: { + "content-type": "application/json" + }, + body: JSON.stringify({ + fileName: "test.txt" + }) + }); + + const res = await handleUpload(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("Invalid JSON"); + }); + + it("should process multipart upload successfully", async () => { + const formData = new FormData(); + const fileBlob = new Blob([Buffer.from("multipart hello")], { type: "text/plain" }); + formData.append("file", fileBlob, "test_multi.txt"); + + const req = new Request("http://localhost:3000/api/upload", { + method: "POST", + body: formData + }); + + const res = await handleUpload(req); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.public_id).toBe("mocked-nanoid-id"); + expect(body.file_name).toBe("test_multi.txt"); + }); +});