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:
+15
-9
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user