diff --git a/src/db/files-ext.ts b/src/db/files-ext.ts index 09bce9b..d28bf5b 100644 --- a/src/db/files-ext.ts +++ b/src/db/files-ext.ts @@ -31,20 +31,21 @@ const mapDbRowToS3Record = (row: Record): S3FileRecord => { publicId: row.public_id as string, telegramFileId: row.telegram_file_id as string, telegramFileUniqueId: row.telegram_file_unique_id as string, - storageChatId: row.storage_chat_id as number, - storageMessageId: row.storage_message_id as number, + storageChatId: toNumber(row.storage_chat_id), + storageMessageId: toNumber(row.storage_message_id), fileName: row.file_name as string, mimeType: row.mime_type as string, - sizeBytes: row.size_bytes as number, + sizeBytes: toNumber(row.size_bytes), fileType: row.file_type as string, - uploaderId: row.uploader_id as number, + uploaderId: toNumber(row.uploader_id), fileHash: row.file_hash as string | null, archiveTelegramFileId: row.archive_telegram_file_id as string | null, - archiveStorageMessageId: row.archive_storage_message_id as number | null, + archiveStorageMessageId: + row.archive_storage_message_id === null ? null : toNumber(row.archive_storage_message_id), archiveFileName: row.archive_file_name as string | null, archiveEntryName: row.archive_entry_name as string | null, archiveMimeType: row.archive_mime_type as string | null, - archiveSizeBytes: row.archive_size_bytes as number | null, + archiveSizeBytes: row.archive_size_bytes === null ? null : toNumber(row.archive_size_bytes), bucketId: row.bucket_id as string, s3Key: row.s3_key as string, storageBackend: (row.storage_backend as string) || 'telegram', @@ -57,6 +58,8 @@ const mapDbRowToS3Record = (row: Record): S3FileRecord => { const escapeLike = (s: string): string => s.replace(/[%_\\]/g, '\\$&'); +const toNumber = (value: unknown): number => Number(value ?? 0); + export const listObjectsByPrefix = async ( bucketId: string, prefix: string, diff --git a/src/env.ts b/src/env.ts index 94e3760..f87aac1 100644 --- a/src/env.ts +++ b/src/env.ts @@ -90,7 +90,8 @@ export const config: AppConfig = { s3DefaultRegion: process.env.S3_DEFAULT_REGION || 'us-east-1', proxyS3Get: process.env.PROXY_S3_GET !== 'false', s3VhostDomains: parseDomains( - process.env.S3_VHOST_DOMAINS || 'upload.asepharyana.my.id,asepharyana.web.id,upload.asepharyana.web.id', + process.env.S3_VHOST_DOMAINS || + 'upload.asepharyana.my.id,asepharyana.web.id,upload.asepharyana.web.id', ), }; diff --git a/src/home.html b/src/home.html index 1c5decc..ad6ab81 100644 --- a/src/home.html +++ b/src/home.html @@ -202,7 +202,7 @@ } container.innerHTML = html; }; - const formatSize = (bytes) => { if (!bytes) return '0 B'; const u = ['B','KB','MB','GB','TB']; let i=0,s=bytes; while(s>=1024&&i0?1:0)} ${u[i]}`; }; + const formatSize = (bytes) => { const size = Number(bytes); if (!Number.isFinite(size) || size <= 0) return '0 B'; const u = ['B','KB','MB','GB','TB']; let i=0,s=size; while(s>=1024&&i0?1:0)} ${u[i]}`; }; const formatDate = (iso) => { if(!iso)return ''; return new Date(iso).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}); }; const escapeHtml = (s) => { const d=document.createElement('div');d.textContent=s;return d.innerHTML; }; const debouncedSearch = () => { clearTimeout(searchTimer); searchTimer = setTimeout(loadObjects, 300); }; diff --git a/src/routes/web-api.ts b/src/routes/web-api.ts index eb7e7a3..c4b049c 100644 --- a/src/routes/web-api.ts +++ b/src/routes/web-api.ts @@ -83,7 +83,7 @@ export const handleListObjectsV1 = async (req: Request, params: RouteParams): Pr key: o.s3Key, fileName: o.fileName, mimeType: o.mimeType, - sizeBytes: o.sizeBytes, + sizeBytes: Number(o.sizeBytes), fileType: o.fileType, etag: o.fileHash, lastModified: diff --git a/test/web-api.test.ts b/test/web-api.test.ts index be8892d..8d77947 100644 --- a/test/web-api.test.ts +++ b/test/web-api.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'; const mockBuckets = [ { @@ -9,6 +9,9 @@ const mockBuckets = [ }, ]; +let mockObjects: Record[] = []; +let mockPrefixes: string[] = []; + mock.module('../src/db/buckets', () => ({ listBuckets: () => Promise.resolve(mockBuckets), findBucketByName: (name: string) => @@ -21,7 +24,7 @@ mock.module('../src/db/buckets', () => ({ mock.module('../src/db/files-ext', () => ({ findFileByBucketAndKey: () => Promise.resolve(null), - listObjectsByPrefix: () => Promise.resolve({ objects: [], prefixes: [] }), + listObjectsByPrefix: () => Promise.resolve({ objects: mockObjects, prefixes: mockPrefixes }), softDeleteFile: () => Promise.resolve(true), softDeleteFilesBatch: () => Promise.resolve(0), countBucketObjects: () => Promise.resolve(0), @@ -56,6 +59,11 @@ describe('Web API v1', () => { handleWebApiV1 = webApi.handleWebApiV1; }); + beforeEach(() => { + mockObjects = []; + mockPrefixes = []; + }); + afterAll(() => { mock.restore(); }); @@ -89,4 +97,27 @@ describe('Web API v1', () => { const data = (await res.json()) as { error: string }; expect(data.error).toContain('Invalid bucket name'); }); + + it('should normalize listed object sizeBytes to a number', async () => { + mockObjects = [ + { + s3Key: 'tiny.txt', + fileName: 'tiny.txt', + mimeType: 'text/plain', + sizeBytes: '12', + fileType: 'document', + fileHash: 'etag', + createdAt: new Date('2026-01-01T00:00:00Z'), + publicId: 'public-id', + }, + ]; + + const req = new Request('http://localhost:3000/api/v1/buckets/test-bucket/objects'); + const res = await handleWebApiV1(req); + + expect(res.status).toBe(200); + const data = (await res.json()) as { objects: { sizeBytes: unknown }[] }; + expect(data.objects[0].sizeBytes).toBe(12); + expect(typeof data.objects[0].sizeBytes).toBe('number'); + }); });