fix: satisfy deploy lint gate for S3 compatibility work

- Apply Biome organize-import/formatting fixes across changed S3 files
- Replace remaining string concatenations with template literals for lint
- Make home page inline handlers explicit via window.* and add button types
- Clean S3 auth lint issues with dot-property access and optional chaining
- Keep GetObject proxy and production/S3 SDK tests passing

Verification:
- bun run lint (0 errors, 1 CSS specificity warning)
- S3_SECRET_KEY=<env> bun test test/production-e2e.test.ts (29 pass)
- S3_SECRET_KEY=<env> bun test test/s3-sdk.test.ts (20 pass)
- bun test test/s3-auth.test.ts (5 pass)
This commit is contained in:
asepharyana
2026-07-07 03:07:51 +07:00
parent 06f93e30f6
commit 9a48fbf227
14 changed files with 161 additions and 124 deletions
+15 -9
View File
@@ -52,15 +52,21 @@ export const listBuckets = async (): Promise<Bucket[]> => {
export const deleteBucket = async (name: string): Promise<boolean> => {
// Cascade-delete rows that hold FK references to the bucket
await db.execute(
sql`DELETE FROM multipart_parts WHERE upload_id IN (SELECT upload_id FROM multipart_uploads WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name}))`,
).catch(() => {});
await db.execute(
sql`DELETE FROM multipart_uploads WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`,
).catch(() => {});
await db.execute(
sql`DELETE FROM files WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`,
).catch(() => {});
await db
.execute(
sql`DELETE FROM multipart_parts WHERE upload_id IN (SELECT upload_id FROM multipart_uploads WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name}))`,
)
.catch(() => {});
await db
.execute(
sql`DELETE FROM multipart_uploads WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`,
)
.catch(() => {});
await db
.execute(
sql`DELETE FROM files WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`,
)
.catch(() => {});
const result = (await db.execute(
sql`DELETE FROM buckets WHERE name = ${name}`,
)) as unknown as QueryResult;
+2 -2
View File
@@ -1,4 +1,4 @@
import { eq, and, sql } from 'drizzle-orm';
import { and, eq, sql } from 'drizzle-orm';
import { db, files as fileSchema } from './index';
import type { File } from './schema';
@@ -62,7 +62,7 @@ export const listObjectsByPrefix = async (
maxKeys: number,
startAfter: string | null,
): Promise<{ objects: S3FileRecord[]; prefixes: string[] }> => {
let query = sql`SELECT * FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false AND s3_key LIKE ${prefix + '%'}`;
let query = sql`SELECT * FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false AND s3_key LIKE ${`${prefix}%`}`;
if (startAfter) {
query = sql`${query} AND s3_key > ${startAfter}`;
+5 -5
View File
@@ -12,10 +12,10 @@ export const runMigration = async (): Promise<void> => {
// In source via bun --hot: import.meta.dir = .../src/db/
const dir = import.meta.dir || '';
const candidates = [
dir + '/../../schema.sql', // from dist/
dir + '/../schema.sql', // from src/ (bun --hot src/index.ts)
dir + '/../schema.sql', // from src/db/ (bun --hot src/db/migrate.ts)
dir + '/schema.sql', // from src/ (bun run db:migrate)
`${dir}/../../schema.sql`, // from dist/
`${dir}/../schema.sql`, // from src/ (bun --hot src/index.ts)
`${dir}/../schema.sql`, // from src/db/ (bun --hot src/db/migrate.ts)
`${dir}/schema.sql`, // from src/ (bun run db:migrate)
];
let schemaSql: string | null = null;
@@ -29,7 +29,7 @@ export const runMigration = async (): Promise<void> => {
}
if (!schemaSql) {
logger.error('Migration failed: schema.sql not found (tried ' + candidates.join(', ') + ')');
logger.error(`Migration failed: schema.sql not found (tried ${candidates.join(', ')})`);
process.exitCode = 1;
return;
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { sql } from 'drizzle-orm';
import { db } from './index';
import { nanoid } from 'nanoid';
import { db } from './index';
export interface MultipartUpload {
uploadId: string;
+11 -10
View File
@@ -118,14 +118,14 @@
<body>
<div class="topbar">
<span class="logo">📦 TeleUploader</span>
<select id="bucketSelect" onchange="switchBucket(this.value)">
<select id="bucketSelect" onchange="window.switchBucket(this.value)">
<option value="">— Select bucket —</option>
</select>
<button onclick="showCreateBucketModal()">+ New</button>
<button onclick="showCredentialsModal()" title="S3 Credentials">🔑</button>
<button type="button" onclick="window.showCreateBucketModal()">+ New</button>
<button type="button" onclick="window.showCredentialsModal()" title="S3 Credentials">🔑</button>
<span class="spacer"></span>
<div class="search">
<input id="searchInput" type="text" placeholder="Filter prefix..." oninput="debouncedSearch()">
<input id="searchInput" type="text" placeholder="Filter prefix..." oninput="window.debouncedSearch()">
</div>
</div>
<div id="breadcrumb" class="breadcrumb" style="display:none;padding:8px 24px"></div>
@@ -156,7 +156,7 @@
const data = await apiJson('/api/v1/buckets');
allBuckets = data.buckets || [];
const sel = document.getElementById('bucketSelect');
sel.innerHTML = '<option value="">— Select bucket —</option>' + allBuckets.map(b => `<option value="${b.name}">${b.name} (${b.objectCount})</option>`).join('');
sel.innerHTML = `<option value="">— Select bucket —</option>${allBuckets.map(b => `<option value="${b.name}">${b.name} (${b.objectCount})</option>`).join('')}`;
if (currentBucket) sel.value = currentBucket;
};
const switchBucket = async (name) => {
@@ -172,9 +172,9 @@
if (!currentPrefix) { bc.style.display = 'none'; return; }
bc.style.display = 'block';
const parts = currentPrefix.split('/').filter(Boolean);
bc.innerHTML = `<span onclick="navigateTo('')">${currentBucket}</span>`;
bc.innerHTML = `<span onclick="window.navigateTo('')">${currentBucket}</span>`;
let accumulated = '';
for (const part of parts) { accumulated += part + '/'; bc.innerHTML += `<span class="sep">/</span><span onclick="navigateTo('${accumulated}')">${part}</span>`; }
for (const part of parts) { accumulated += `${part}/`; bc.innerHTML += `<span class="sep">/</span><span onclick="window.navigateTo('${accumulated}')">${part}</span>`; }
};
const navigateTo = (prefix) => { currentPrefix = prefix; loadObjects(); };
const loadObjects = async () => {
@@ -194,7 +194,7 @@
let html = '';
for (const prefix of currentPrefixes) {
const displayName = prefix.replace(currentPrefix, '');
html += `<div class="file-row" onclick="navigateTo('${prefix}')"><span class="icon">🗂</span><span class="name">${displayName.endsWith('/') ? displayName : displayName + '/'}</span><span class="size">—</span><span class="date"></span><span class="actions"></span></div>`;
html += `<div class="file-row" onclick="window.navigateTo('${prefix}')"><span class="icon">🗂</span><span class="name">${displayName.endsWith('/') ? displayName : `${displayName}/`}</span><span class="size">—</span><span class="date"></span><span class="actions"></span></div>`;
}
for (const obj of currentObjects) {
const displayName = obj.key.replace(currentPrefix, '');
@@ -222,7 +222,7 @@
await new Promise((resolve,reject)=>{
const fd=new FormData(); fd.append('file',file); fd.append('key',currentPrefix+file.name);
const xhr=new XMLHttpRequest();
xhr.upload.onprogress=(e)=>{if(e.lengthComputable){const p=Math.round((e.loaded/e.total)*100);fill.style.width=p+'%';pp.textContent=p+'%';}};
xhr.upload.onprogress=(e)=>{if(e.lengthComputable){const p=Math.round((e.loaded/e.total)*100);fill.style.width=`${p}%`;pp.textContent=`${p}%`;}};
xhr.onload=()=>{if(xhr.status>=200&&xhr.status<300)resolve();else reject(new Error(xhr.statusText));};
xhr.onerror=()=>reject(new Error('Upload failed'));
xhr.open('POST',`/api/v1/buckets/${encodeURIComponent(currentBucket)}/upload`); xhr.send(fd);
@@ -239,7 +239,8 @@
const closeModal=(e)=>{if(e&&e.target!==e.currentTarget)return;document.getElementById('modalOverlay').style.display='none';};
const showCreateBucketModal=()=>{showModal(`<h3>Create Bucket</h3><input id="bucketNameInput" type="text" placeholder="my-bucket-name" pattern="[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]"><p style="font-size:0.8rem;color:var(--text2);margin-bottom:12px">Lowercase, 3-63 chars, no underscores</p><div class="buttons"><button onclick="closeModal()">Cancel</button><button class="primary" onclick="createBucket()">Create</button></div>`);setTimeout(()=>document.getElementById('bucketNameInput')?.focus(),100);};
const createBucket=async()=>{const n=document.getElementById('bucketNameInput').value.trim();if(!n)return;try{await apiJson('/api/v1/buckets',{method:'POST',body:JSON.stringify({name:n})});closeModal();await loadBuckets();document.getElementById('bucketSelect').value=n;await switchBucket(n);}catch(e){alert(`Failed: ${e.message}`);}};
const showCredentialsModal=()=>{showModal(`<h3>S3 Credentials</h3><p style="margin-bottom:12px;font-size:0.85rem;color:var(--text2)">Use these in any S3 client (aws-cli, rclone, s3cmd, etc.)</p><label style="font-size:0.85rem;font-weight:600">Endpoint URL</label><input type="text" value="${window.location.origin}" readonly onclick="this.select()"><label style="font-size:0.85rem;font-weight:600">Region</label><input type="text" value="us-east-1" readonly onclick="this.select()"><label style="font-size:0.85rem;font-weight:600">Access Key</label><input id="s3AccessKey" type="text" readonly onclick="this.select()"><label style="font-size:0.85rem;font-weight:600">Secret Key</label><input id="s3SecretKey" type="password" readonly onclick="this.select()"><div class="buttons"><button onclick="closeModal()">Close</button></div>`);};
const showCredentialsModal=()=>{showModal(`<h3>S3 Credentials</h3><p style="margin-bottom:12px;font-size:0.85rem;color:var(--text2)">Use these in any S3 client (aws-cli, rclone, s3cmd, etc.)</p><label style="font-size:0.85rem;font-weight:600">Endpoint URL</label><input type="text" value="${window.location.origin}" readonly onclick="this.select()"><label style="font-size:0.85rem;font-weight:600">Region</label><input type="text" value="us-east-1" readonly onclick="this.select()"><label style="font-size:0.85rem;font-weight:600">Access Key</label><input id="s3AccessKey" type="text" readonly onclick="this.select()"><label style="font-size:0.85rem;font-weight:600">Secret Key</label><input id="s3SecretKey" type="password" readonly onclick="this.select()"><div class="buttons"><button type="button" onclick="window.closeModal()">Close</button></div>`);};
Object.assign(window, { switchBucket, navigateTo, debouncedSearch, downloadObject, copyLink, deleteObject, closeModal, showCreateBucketModal, createBucket, showCredentialsModal });
loadBuckets();
</script>
</body>
+3 -3
View File
@@ -3,16 +3,16 @@ import { startBot } from './bot';
import { config } from './env';
import { handleFileInfo, handleFileRedirect } from './routes/files';
import { handleHealth } from './routes/health';
import { handleHome } from './routes/home';
import { handleS3Request } from './routes/s3';
import { handleSwaggerHtml, handleSwaggerJson } from './routes/swagger';
import { handleUpload } from './routes/upload';
import { handleHome } from './routes/home';
import { handleWebApiV1 } from './routes/web-api';
import { handleS3Request } from './routes/s3';
import { isS3Request } from './utils/s3/auth';
import { fileInfoCache } from './utils/cache';
import logger from './utils/logger';
import { metricsCollector } from './utils/metrics';
import { cleanupRateLimitCache, withRateLimit } from './utils/rateLimit';
import { isS3Request } from './utils/s3/auth';
// ─── Auto-run migration at startup ──────────────────────────────────────────
try {
+1 -1
View File
@@ -1,5 +1,5 @@
export const handleHome = async (): Promise<Response> => {
const html = await Bun.file(import.meta.dir + '/../home.html').text();
const html = await Bun.file(`${import.meta.dir}/../home.html`).text();
return new Response(html, {
status: 200,
headers: {
+28 -28
View File
@@ -1,39 +1,39 @@
import { verifySignature, verifyPresignedUrl } from '../utils/s3/auth';
import {
listBucketsXml,
s3ErrorResponse,
listBucketResultXml,
listBucketV2ResultXml,
initiateMultipartUploadXml,
listPartsXml,
completeMultipartUploadXml,
deleteResultXml,
copyObjectResultXml,
parseDeleteObjectsBody,
parseCompleteMultipartBody,
} from '../utils/s3/xml';
import { createBucket, findBucketByName, listBuckets, deleteBucket } from '../db/buckets';
import {
createMultipartUpload,
findMultipartUpload,
completeMultipartUpload,
abortMultipartUpload,
insertMultipartPart,
listMultipartParts,
} from '../db/multipart';
import { createReadStream } from 'node:fs';
import { nanoid } from 'nanoid';
import { createBucket, deleteBucket, findBucketByName, listBuckets } from '../db/buckets';
import {
countBucketObjects,
findFileByBucketAndKey,
listObjectsByPrefix,
softDeleteFile,
countBucketObjects,
} from '../db/files-ext';
import {
abortMultipartUpload,
completeMultipartUpload,
createMultipartUpload,
findMultipartUpload,
insertMultipartPart,
listMultipartParts,
} from '../db/multipart';
import type { File } from '../db/schema';
import { config } from '../env';
import { forwardToStorage, getFileInfo } from '../utils/telegram';
import { computeHash, ensureExtension, getErrorMessage, cleanupTempFile } from '../utils/file';
import { nanoid } from 'nanoid';
import { createReadStream } from 'node:fs';
import { cleanupTempFile, computeHash, ensureExtension, getErrorMessage } from '../utils/file';
import logger from '../utils/logger';
import { verifyPresignedUrl, verifySignature } from '../utils/s3/auth';
import {
completeMultipartUploadXml,
copyObjectResultXml,
deleteResultXml,
initiateMultipartUploadXml,
listBucketResultXml,
listBucketsXml,
listBucketV2ResultXml,
listPartsXml,
parseCompleteMultipartBody,
parseDeleteObjectsBody,
s3ErrorResponse,
} from '../utils/s3/xml';
import { forwardToStorage, getFileInfo } from '../utils/telegram';
const REGION = config.s3DefaultRegion || 'us-east-1';
const REQUEST_ID = () => nanoid(16);
+6 -6
View File
@@ -1,16 +1,16 @@
import { createBucket, findBucketByName, listBuckets, deleteBucket } from '../db/buckets';
import { createReadStream } from 'node:fs';
import { nanoid } from 'nanoid';
import { createBucket, deleteBucket, findBucketByName, listBuckets } from '../db/buckets';
import {
countBucketObjects,
findFileByBucketAndKey,
listObjectsByPrefix,
softDeleteFile,
countBucketObjects,
} from '../db/files-ext';
import { createReadStream } from 'node:fs';
import { config } from '../env';
import { forwardToStorage, getFileInfo } from '../utils/telegram';
import { computeHash, ensureExtension, getErrorMessage, cleanupTempFile } from '../utils/file';
import { nanoid } from 'nanoid';
import { cleanupTempFile, computeHash, ensureExtension, getErrorMessage } from '../utils/file';
import logger from '../utils/logger';
import { forwardToStorage, getFileInfo } from '../utils/telegram';
type RouteParams = { bucket?: string; key?: string };
+3 -3
View File
@@ -131,8 +131,8 @@ export const verifySignature = async (
s3SecretKey: string,
region: string,
): Promise<SigV4Result> => {
const authHeader = headers['authorization'];
if (!authHeader || !authHeader.startsWith('AWS4-HMAC-SHA256')) {
const authHeader = headers.authorization;
if (!authHeader?.startsWith('AWS4-HMAC-SHA256')) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
@@ -276,6 +276,6 @@ export const verifyPresignedUrl = async (
};
export const isS3Request = (headers: Record<string, string>): boolean => {
const auth = headers['authorization'] || '';
const auth = headers.authorization || '';
return auth.startsWith('AWS4-HMAC-SHA256');
};
+5 -10
View File
@@ -12,7 +12,7 @@ const isoDate = (d: Date): string => d.toISOString().replace(/\.\d{3}Z$/, 'Z');
export const listBucketsXml = (
buckets: { name: string; createdAt: Date }[],
requestId: string,
_requestId: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Buckets>
@@ -39,7 +39,7 @@ export const listBucketResultXml = (
prefix: string,
delimiter: string | null,
nextMarker: string | null,
requestId: string,
_requestId: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>${escapeXml(bucketName)}</Name>
@@ -80,7 +80,7 @@ export const listBucketV2ResultXml = (
continuationToken: string | null,
nextContinuationToken: string | null,
keyCount: number,
requestId: string,
_requestId: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResultV2 xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>${escapeXml(bucketName)}</Name>
@@ -131,7 +131,7 @@ export const listPartsXml = (
parts: { partNumber: number; etag: string; sizeBytes: number; createdAt: Date }[],
maxParts: number,
isTruncated: boolean,
requestId: string,
_requestId: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<ListPartsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Bucket>${escapeXml(bucketName)}</Bucket>
@@ -233,12 +233,7 @@ export const s3ErrorResponse = (
// ─────── DeleteObjects XML parser ───────
export const parseDeleteObjectsBody = (body: string): { keys: string[]; quiet: boolean } => {
const keys: string[] = [];
const keyRegex = /<Key>([^<]+)<\/Key>/g;
let match;
while ((match = keyRegex.exec(body)) !== null) {
keys.push(match[1]);
}
const keys = Array.from(body.matchAll(/<Key>([^<]+)<\/Key>/g), (match) => match[1]);
const quiet = body.includes('<Quiet>true</Quiet>') || body.includes('<Quiet>true ');
return { keys, quiet };
};