feat: implement deduplication in HTTP upload router

- Compute SHA-256 hash using computeHash for multipart and JSON uploads
- Query database for existing file records with the same hash
- Return existing metadata and download URL immediately on duplicate match without reforwarding to Telegram
- Include fileHash in newly inserted file database records

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-05-18 19:39:55 +07:00
co-authored by Claude Opus 4.7
parent 4e805689c1
commit 04a2e52250
2 changed files with 194 additions and 15 deletions
+64 -1
View File
@@ -1,7 +1,8 @@
import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { db, files as fileSchema } from '../db'; import { db, files as fileSchema } from '../db';
import { config } from '../env'; import { config } from '../env';
import { checkFileSize, ensureExtension, extractMimeType, getFileType } from '../utils/file'; import { checkFileSize, computeHash, ensureExtension, extractMimeType, getFileType } from '../utils/file';
import logger from '../utils/logger'; import logger from '../utils/logger';
import { forwardToStorage, getBot } from '../utils/telegram'; import { forwardToStorage, getBot } from '../utils/telegram';
@@ -38,6 +39,36 @@ const handleMultipartUpload = async (req: Request): Promise<Response> => {
const fileBytes = await file.arrayBuffer(); const fileBytes = await file.arrayBuffer();
const fileBuffer = Buffer.from(fileBytes); const fileBuffer = Buffer.from(fileBytes);
const hash = computeHash(fileBuffer);
// Check for duplicate in DB
const existing = await db
.select()
.from(fileSchema)
.where(eq(fileSchema.fileHash, hash))
.limit(1);
if (existing.length > 0) {
const existingFile = existing[0];
const responsePayload = {
public_id: existingFile.publicId,
telegram_file_id: existingFile.telegramFileId,
telegram_file_unique_id: existingFile.telegramFileUniqueId,
storage_chat_id: existingFile.storageChatId,
storage_message_id: existingFile.storageMessageId,
file_name: existingFile.fileName,
mime_type: existingFile.mimeType,
size_bytes: existingFile.sizeBytes,
file_type: existingFile.fileType,
uploader_id: existingFile.uploaderId,
created_at: existingFile.createdAt instanceof Date
? existingFile.createdAt.toISOString()
: new Date(existingFile.createdAt).toISOString(),
download_url: `${config.baseUrl}/f/${existingFile.publicId}`,
};
return Response.json(responsePayload, { status: 200 });
}
const rawMimeType = file.type || extractMimeType({}, req) || 'application/octet-stream'; const rawMimeType = file.type || extractMimeType({}, req) || 'application/octet-stream';
const { fileName: finalFileName, mimeType } = ensureExtension( const { fileName: finalFileName, mimeType } = ensureExtension(
fileName, fileName,
@@ -69,6 +100,7 @@ const handleMultipartUpload = async (req: Request): Promise<Response> => {
sizeBytes: fileInfo.file_size || fileBuffer.byteLength, sizeBytes: fileInfo.file_size || fileBuffer.byteLength,
fileType: fileType, fileType: fileType,
uploaderId: 0, uploaderId: 0,
fileHash: hash,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}; };
@@ -119,6 +151,36 @@ const handleJSONUpload = async (req: Request): Promise<Response> => {
} }
const fileBytes = Buffer.from(base64Data, 'base64'); const fileBytes = Buffer.from(base64Data, 'base64');
const hash = computeHash(fileBytes);
// Check for duplicate in DB
const existing = await db
.select()
.from(fileSchema)
.where(eq(fileSchema.fileHash, hash))
.limit(1);
if (existing.length > 0) {
const existingFile = existing[0];
const responsePayload = {
public_id: existingFile.publicId,
telegram_file_id: existingFile.telegramFileId,
telegram_file_unique_id: existingFile.telegramFileUniqueId,
storage_chat_id: existingFile.storageChatId,
storage_message_id: existingFile.storageMessageId,
file_name: existingFile.fileName,
mime_type: existingFile.mimeType,
size_bytes: existingFile.sizeBytes,
file_type: existingFile.fileType,
uploader_id: existingFile.uploaderId,
created_at: existingFile.createdAt instanceof Date
? existingFile.createdAt.toISOString()
: new Date(existingFile.createdAt).toISOString(),
download_url: `${config.baseUrl}/f/${existingFile.publicId}`,
};
return Response.json(responsePayload, { status: 200 });
}
const { fileName: finalFileName, mimeType } = ensureExtension(fileName, fileBytes, rawMimeType); const { fileName: finalFileName, mimeType } = ensureExtension(fileName, fileBytes, rawMimeType);
const fileType = const fileType =
getFileType(mimeType, finalFileName) === 'application' getFileType(mimeType, finalFileName) === 'application'
@@ -148,6 +210,7 @@ const handleJSONUpload = async (req: Request): Promise<Response> => {
sizeBytes: fileInfo.file_size || fileBytes.byteLength, sizeBytes: fileInfo.file_size || fileBytes.byteLength,
fileType: fileType, fileType: fileType,
uploaderId: 0, uploaderId: 0,
fileHash: hash,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}; };
+130 -14
View File
@@ -2,6 +2,19 @@
import { afterAll, beforeEach, describe, expect, it, mock } from 'bun:test'; import { afterAll, beforeEach, describe, expect, it, mock } from 'bun:test';
// Mock db // Mock db
let mockSelectResult: any[] = [];
const mockLimit = mock(() => Promise.resolve(mockSelectResult));
const mockWhere = mock(() => ({
limit: mockLimit,
}));
const mockFrom = mock(() => ({
where: mockWhere,
}));
const mockSelect = mock(() => ({
from: mockFrom,
}));
const mockInsert = mock(() => ({ const mockInsert = mock(() => ({
values: mock(() => Promise.resolve()), values: mock(() => Promise.resolve()),
})); }));
@@ -9,6 +22,7 @@ const mockInsert = mock(() => ({
mock.module('../src/db/index', () => ({ mock.module('../src/db/index', () => ({
db: { db: {
insert: mockInsert, insert: mockInsert,
select: mockSelect,
}, },
files: {}, files: {},
})); }));
@@ -19,23 +33,27 @@ mock.module('nanoid', () => ({
})); }));
// Mock telegram utils // Mock telegram utils
const mockForwardToStorage = mock(() =>
Promise.resolve({
telegramFileId: 'tg-file-id-123',
telegramFileUniqueId: 'tg-unique-id-abc',
storageMessageId: 98765,
}),
);
const mockGetFile = mock(() =>
Promise.resolve({
file_id: 'tg-file-id-123',
file_size: 1000,
mime_type: 'image/jpeg',
}),
);
mock.module('../src/utils/telegram', () => ({ mock.module('../src/utils/telegram', () => ({
forwardToStorage: mock(() => forwardToStorage: mockForwardToStorage,
Promise.resolve({
telegramFileId: 'tg-file-id-123',
telegramFileUniqueId: 'tg-unique-id-abc',
storageMessageId: 98765,
}),
),
getBot: () => ({ getBot: () => ({
telegram: { telegram: {
getFile: mock(() => getFile: mockGetFile,
Promise.resolve({
file_id: 'tg-file-id-123',
file_size: 1000,
mime_type: 'image/jpeg',
}),
),
}, },
}), }),
})); }));
@@ -45,6 +63,13 @@ describe('Upload Route Handler', () => {
beforeEach(async () => { beforeEach(async () => {
mockInsert.mockClear(); mockInsert.mockClear();
mockSelect.mockClear();
mockFrom.mockClear();
mockWhere.mockClear();
mockLimit.mockClear();
mockForwardToStorage.mockClear();
mockGetFile.mockClear();
mockSelectResult = [];
const uploadRoute = await import('../src/routes/upload'); const uploadRoute = await import('../src/routes/upload');
handleUpload = uploadRoute.handleUpload; handleUpload = uploadRoute.handleUpload;
}); });
@@ -121,6 +146,97 @@ describe('Upload Route Handler', () => {
expect(body.file_name).toBe('test_multi.txt'); expect(body.file_name).toBe('test_multi.txt');
}); });
it('should deduplicate multipart upload if hash exists', async () => {
mockSelectResult = [
{
publicId: 'existing-id-123',
telegramFileId: 'existing-tg-id',
telegramFileUniqueId: 'existing-tg-unique',
storageChatId: 12345,
storageMessageId: 67890,
fileName: 'existing_name.txt',
mimeType: 'text/plain',
sizeBytes: 100,
fileType: 'document',
uploaderId: 0,
createdAt: new Date('2026-05-18T00:00:00.000Z'),
updatedAt: new Date('2026-05-18T00:00:00.000Z'),
},
];
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('existing-id-123');
expect(body.telegram_file_id).toBe('existing-tg-id');
expect(body.telegram_file_unique_id).toBe('existing-tg-unique');
expect(body.file_name).toBe('existing_name.txt');
expect(body.download_url).toContain('/f/existing-id-123');
// DB query happened
expect(mockSelect).toHaveBeenCalled();
// No telegram upload happened
expect(mockForwardToStorage).not.toHaveBeenCalled();
// No db insertion happened
expect(mockInsert).not.toHaveBeenCalled();
});
it('should deduplicate JSON upload if hash exists', async () => {
mockSelectResult = [
{
publicId: 'existing-json-id',
telegramFileId: 'existing-tg-json-id',
telegramFileUniqueId: 'existing-tg-json-unique',
storageChatId: 12345,
storageMessageId: 67890,
fileName: 'existing_json.txt',
mimeType: 'text/plain',
sizeBytes: 200,
fileType: 'document',
uploaderId: 0,
createdAt: new Date('2026-05-18T00:00:00.000Z'),
updatedAt: new Date('2026-05-18T00:00:00.000Z'),
},
];
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('existing-json-id');
expect(body.telegram_file_id).toBe('existing-tg-json-id');
expect(body.file_name).toBe('existing_json.txt');
expect(body.download_url).toContain('/f/existing-json-id');
// DB query happened
expect(mockSelect).toHaveBeenCalled();
// No telegram upload happened
expect(mockForwardToStorage).not.toHaveBeenCalled();
// No db insertion happened
expect(mockInsert).not.toHaveBeenCalled();
});
afterAll(() => { afterAll(() => {
mock.restore(); mock.restore();
}); });