diff --git a/src/routes/files.js b/src/routes/files.js new file mode 100644 index 0000000..772ce22 --- /dev/null +++ b/src/routes/files.js @@ -0,0 +1,68 @@ +import logger from '../utils/logger.js'; +import { db, files as fileSchema } from '../db/index.js'; +import { checkRateLimit } from '../utils/rateLimit.js'; +import { eq } from 'drizzle-orm'; + +export const handleFileRedirect = async (req, ctx) => { + const public_id = ctx?.params?.public_id; + try { + const ip = req.headers.get('x-forwarded-for') || '127.0.0.1'; + + if (!public_id || !checkRateLimit(ip)) { + return Response.json({ error: 'Rate limit exceeded' }, { status: 429 }); + } + + const result = await db.select().from(fileSchema).where(eq(fileSchema.publicId, public_id)).limit(1); + + if (!result.length) { + logger.warn('File not found', { public_id }); + return Response.json({ error: 'File not found' }, { status: 404 }); + } + + const file = result[0]; + const { getBot } = await import('../utils/telegram.js'); + const bot = getBot(); + const fileInfo = await bot.api.getFile(file.telegramFileId); + + const redirectUrl = `https://api.telegram.org/file/bot${process.env.BOT_TOKEN}/${fileInfo.file_path}`; + return new Response(null, { + status: 302, + headers: { + 'Location': redirectUrl + } + }); + } catch (error) { + logger.error('File redirect error', { public_id, error: error.message }); + return Response.json({ error: 'Server error' }, { status: 500 }); + } +}; + +export const handleFileInfo = async (req, ctx) => { + const public_id = ctx?.params?.public_id; + try { + if (!public_id) { + return Response.json({ error: 'Missing file id' }, { status: 400 }); + } + + const result = await db.select().from(fileSchema).where(eq(fileSchema.publicId, public_id)).limit(1); + + if (!result.length) { + logger.warn('File not found', { public_id }); + return Response.json({ error: 'File not found' }, { status: 404 }); + } + + const file = result[0]; + return Response.json({ + public_id: file.publicId, + file_name: file.fileName, + mime_type: file.mimeType, + size_bytes: file.sizeBytes, + file_type: file.fileType, + uploader_id: file.uploaderId, + created_at: file.createdAt.toISOString ? file.createdAt.toISOString() : file.createdAt + }, { status: 200 }); + } catch (error) { + logger.error('File info error', { public_id, error: error.message }); + return Response.json({ error: 'Server error' }, { status: 500 }); + } +}; diff --git a/test/files.test.js b/test/files.test.js new file mode 100644 index 0000000..98a299f --- /dev/null +++ b/test/files.test.js @@ -0,0 +1,179 @@ +import { describe, it, expect, mock, spyOn, beforeEach } from "bun:test"; +import logger from "../src/utils/logger.js"; + +// Mock database layer +const mockSelect = mock(() => ({ + from: mock(() => ({ + where: mock(() => ({ + limit: mock(() => Promise.resolve([])) + })) + })) +})); + +mock.module("../src/db/index.js", () => ({ + db: { + select: mockSelect + }, + files: { + publicId: { + equals: (val) => ({ type: "equals", value: val }) + } + } +})); + +// Mock telegram utils +const mockGetFile = mock(() => Promise.resolve({ file_path: "photos/file_0.jpg" })); +mock.module("../src/utils/telegram.js", () => ({ + getBot: () => ({ + api: { + getFile: mockGetFile + } + }) +})); + +// Mock rateLimit +const mockCheckRateLimit = mock(() => true); +mock.module("../src/utils/rateLimit.js", () => ({ + checkRateLimit: mockCheckRateLimit +})); + +describe("File Route Handlers", () => { + let handleFileRedirect, handleFileInfo; + + beforeEach(async () => { + mockSelect.mockClear(); + mockGetFile.mockClear(); + mockCheckRateLimit.mockClear(); + + // Set up mock token + process.env.BOT_TOKEN = "123456:ABC-DEF"; + + const filesRoute = await import("../src/routes/files.js"); + handleFileRedirect = filesRoute.handleFileRedirect; + handleFileInfo = filesRoute.handleFileInfo; + }); + + describe("handleFileRedirect", () => { + it("should return 429 if rate limit is exceeded", async () => { + mockCheckRateLimit.mockImplementationOnce(() => false); + const req = new Request("http://localhost:3000/f/test-id"); + + const res = await handleFileRedirect(req, { params: { public_id: "test-id" } }); + expect(res.status).toBe(429); + const body = await res.json(); + expect(body.error).toBe("Rate limit exceeded"); + }); + + it("should return 404 if file is not found in database", async () => { + mockSelect.mockImplementationOnce(() => ({ + from: () => ({ + where: () => ({ + limit: () => Promise.resolve([]) + }) + }) + })); + + const req = new Request("http://localhost:3000/f/missing-id"); + const res = await handleFileRedirect(req, { params: { public_id: "missing-id" } }); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("File not found"); + }); + + it("should redirect to telegram file url if file is found", async () => { + mockSelect.mockImplementationOnce(() => ({ + from: () => ({ + where: () => ({ + limit: () => Promise.resolve([{ + id: "uuid-123", + publicId: "test-id", + telegramFileId: "tg-file-id", + fileName: "test.jpg" + }]) + }) + }) + })); + + const req = new Request("http://localhost:3000/f/test-id"); + const res = await handleFileRedirect(req, { params: { public_id: "test-id" } }); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("https://api.telegram.org/file/bot123456:ABC-DEF/photos/file_0.jpg"); + }); + + it("should return 500 on database or external errors", async () => { + mockSelect.mockImplementationOnce(() => { + throw new Error("DB Connection Error"); + }); + + const req = new Request("http://localhost:3000/f/test-id"); + const res = await handleFileRedirect(req, { params: { public_id: "test-id" } }); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("Server error"); + }); + }); + + describe("handleFileInfo", () => { + it("should return 404 if file is not found in database", async () => { + mockSelect.mockImplementationOnce(() => ({ + from: () => ({ + where: () => ({ + limit: () => Promise.resolve([]) + }) + }) + })); + + const req = new Request("http://localhost:3000/file/missing-id/info"); + const res = await handleFileInfo(req, { params: { public_id: "missing-id" } }); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("File not found"); + }); + + it("should return file info JSON if file is found", async () => { + const dbFile = { + publicId: "test-id", + fileName: "image.png", + mimeType: "image/png", + sizeBytes: 2048, + fileType: "photo", + uploaderId: 99999, + createdAt: new Date("2026-05-18T00:00:00.000Z") + }; + + mockSelect.mockImplementationOnce(() => ({ + from: () => ({ + where: () => ({ + limit: () => Promise.resolve([dbFile]) + }) + }) + })); + + const req = new Request("http://localhost:3000/file/test-id/info"); + const res = await handleFileInfo(req, { params: { public_id: "test-id" } }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ + public_id: "test-id", + file_name: "image.png", + mime_type: "image/png", + size_bytes: 2048, + file_type: "photo", + uploader_id: 99999, + created_at: "2026-05-18T00:00:00.000Z" + }); + }); + + it("should return 500 on database or external errors", async () => { + mockSelect.mockImplementationOnce(() => { + throw new Error("DB Connection Error"); + }); + + const req = new Request("http://localhost:3000/file/test-id/info"); + const res = await handleFileInfo(req, { params: { public_id: "test-id" } }); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("Server error"); + }); + }); +});