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:
+12
-6
@@ -52,15 +52,21 @@ export const listBuckets = async (): Promise<Bucket[]> => {
|
|||||||
|
|
||||||
export const deleteBucket = async (name: string): Promise<boolean> => {
|
export const deleteBucket = async (name: string): Promise<boolean> => {
|
||||||
// Cascade-delete rows that hold FK references to the bucket
|
// Cascade-delete rows that hold FK references to the bucket
|
||||||
await db.execute(
|
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}))`,
|
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(
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.execute(
|
||||||
sql`DELETE FROM multipart_uploads WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`,
|
sql`DELETE FROM multipart_uploads WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`,
|
||||||
).catch(() => {});
|
)
|
||||||
await db.execute(
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.execute(
|
||||||
sql`DELETE FROM files WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`,
|
sql`DELETE FROM files WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`,
|
||||||
).catch(() => {});
|
)
|
||||||
|
.catch(() => {});
|
||||||
const result = (await db.execute(
|
const result = (await db.execute(
|
||||||
sql`DELETE FROM buckets WHERE name = ${name}`,
|
sql`DELETE FROM buckets WHERE name = ${name}`,
|
||||||
)) as unknown as QueryResult;
|
)) 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 { db, files as fileSchema } from './index';
|
||||||
import type { File } from './schema';
|
import type { File } from './schema';
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ export const listObjectsByPrefix = async (
|
|||||||
maxKeys: number,
|
maxKeys: number,
|
||||||
startAfter: string | null,
|
startAfter: string | null,
|
||||||
): Promise<{ objects: S3FileRecord[]; prefixes: string[] }> => {
|
): 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) {
|
if (startAfter) {
|
||||||
query = sql`${query} AND s3_key > ${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/
|
// In source via bun --hot: import.meta.dir = .../src/db/
|
||||||
const dir = import.meta.dir || '';
|
const dir = import.meta.dir || '';
|
||||||
const candidates = [
|
const candidates = [
|
||||||
dir + '/../../schema.sql', // from dist/
|
`${dir}/../../schema.sql`, // from dist/
|
||||||
dir + '/../schema.sql', // from src/ (bun --hot src/index.ts)
|
`${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/db/ (bun --hot src/db/migrate.ts)
|
||||||
dir + '/schema.sql', // from src/ (bun run db:migrate)
|
`${dir}/schema.sql`, // from src/ (bun run db:migrate)
|
||||||
];
|
];
|
||||||
|
|
||||||
let schemaSql: string | null = null;
|
let schemaSql: string | null = null;
|
||||||
@@ -29,7 +29,7 @@ export const runMigration = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!schemaSql) {
|
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;
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { db } from './index';
|
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
import { db } from './index';
|
||||||
|
|
||||||
export interface MultipartUpload {
|
export interface MultipartUpload {
|
||||||
uploadId: string;
|
uploadId: string;
|
||||||
|
|||||||
+11
-10
@@ -118,14 +118,14 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<span class="logo">📦 TeleUploader</span>
|
<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>
|
<option value="">— Select bucket —</option>
|
||||||
</select>
|
</select>
|
||||||
<button onclick="showCreateBucketModal()">+ New</button>
|
<button type="button" onclick="window.showCreateBucketModal()">+ New</button>
|
||||||
<button onclick="showCredentialsModal()" title="S3 Credentials">🔑</button>
|
<button type="button" onclick="window.showCredentialsModal()" title="S3 Credentials">🔑</button>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<div class="search">
|
<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>
|
</div>
|
||||||
<div id="breadcrumb" class="breadcrumb" style="display:none;padding:8px 24px"></div>
|
<div id="breadcrumb" class="breadcrumb" style="display:none;padding:8px 24px"></div>
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
const data = await apiJson('/api/v1/buckets');
|
const data = await apiJson('/api/v1/buckets');
|
||||||
allBuckets = data.buckets || [];
|
allBuckets = data.buckets || [];
|
||||||
const sel = document.getElementById('bucketSelect');
|
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;
|
if (currentBucket) sel.value = currentBucket;
|
||||||
};
|
};
|
||||||
const switchBucket = async (name) => {
|
const switchBucket = async (name) => {
|
||||||
@@ -172,9 +172,9 @@
|
|||||||
if (!currentPrefix) { bc.style.display = 'none'; return; }
|
if (!currentPrefix) { bc.style.display = 'none'; return; }
|
||||||
bc.style.display = 'block';
|
bc.style.display = 'block';
|
||||||
const parts = currentPrefix.split('/').filter(Boolean);
|
const parts = currentPrefix.split('/').filter(Boolean);
|
||||||
bc.innerHTML = `<span onclick="navigateTo('')">${currentBucket}</span>`;
|
bc.innerHTML = `<span onclick="window.navigateTo('')">${currentBucket}</span>`;
|
||||||
let accumulated = '';
|
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 navigateTo = (prefix) => { currentPrefix = prefix; loadObjects(); };
|
||||||
const loadObjects = async () => {
|
const loadObjects = async () => {
|
||||||
@@ -194,7 +194,7 @@
|
|||||||
let html = '';
|
let html = '';
|
||||||
for (const prefix of currentPrefixes) {
|
for (const prefix of currentPrefixes) {
|
||||||
const displayName = prefix.replace(currentPrefix, '');
|
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) {
|
for (const obj of currentObjects) {
|
||||||
const displayName = obj.key.replace(currentPrefix, '');
|
const displayName = obj.key.replace(currentPrefix, '');
|
||||||
@@ -222,7 +222,7 @@
|
|||||||
await new Promise((resolve,reject)=>{
|
await new Promise((resolve,reject)=>{
|
||||||
const fd=new FormData(); fd.append('file',file); fd.append('key',currentPrefix+file.name);
|
const fd=new FormData(); fd.append('file',file); fd.append('key',currentPrefix+file.name);
|
||||||
const xhr=new XMLHttpRequest();
|
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.onload=()=>{if(xhr.status>=200&&xhr.status<300)resolve();else reject(new Error(xhr.statusText));};
|
||||||
xhr.onerror=()=>reject(new Error('Upload failed'));
|
xhr.onerror=()=>reject(new Error('Upload failed'));
|
||||||
xhr.open('POST',`/api/v1/buckets/${encodeURIComponent(currentBucket)}/upload`); xhr.send(fd);
|
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 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 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 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();
|
loadBuckets();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+3
-3
@@ -3,16 +3,16 @@ import { startBot } from './bot';
|
|||||||
import { config } from './env';
|
import { config } from './env';
|
||||||
import { handleFileInfo, handleFileRedirect } from './routes/files';
|
import { handleFileInfo, handleFileRedirect } from './routes/files';
|
||||||
import { handleHealth } from './routes/health';
|
import { handleHealth } from './routes/health';
|
||||||
|
import { handleHome } from './routes/home';
|
||||||
|
import { handleS3Request } from './routes/s3';
|
||||||
import { handleSwaggerHtml, handleSwaggerJson } from './routes/swagger';
|
import { handleSwaggerHtml, handleSwaggerJson } from './routes/swagger';
|
||||||
import { handleUpload } from './routes/upload';
|
import { handleUpload } from './routes/upload';
|
||||||
import { handleHome } from './routes/home';
|
|
||||||
import { handleWebApiV1 } from './routes/web-api';
|
import { handleWebApiV1 } from './routes/web-api';
|
||||||
import { handleS3Request } from './routes/s3';
|
|
||||||
import { isS3Request } from './utils/s3/auth';
|
|
||||||
import { fileInfoCache } from './utils/cache';
|
import { fileInfoCache } from './utils/cache';
|
||||||
import logger from './utils/logger';
|
import logger from './utils/logger';
|
||||||
import { metricsCollector } from './utils/metrics';
|
import { metricsCollector } from './utils/metrics';
|
||||||
import { cleanupRateLimitCache, withRateLimit } from './utils/rateLimit';
|
import { cleanupRateLimitCache, withRateLimit } from './utils/rateLimit';
|
||||||
|
import { isS3Request } from './utils/s3/auth';
|
||||||
|
|
||||||
// ─── Auto-run migration at startup ──────────────────────────────────────────
|
// ─── Auto-run migration at startup ──────────────────────────────────────────
|
||||||
try {
|
try {
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
export const handleHome = async (): Promise<Response> => {
|
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, {
|
return new Response(html, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
+28
-28
@@ -1,39 +1,39 @@
|
|||||||
import { verifySignature, verifyPresignedUrl } from '../utils/s3/auth';
|
import { createReadStream } from 'node:fs';
|
||||||
import {
|
import { nanoid } from 'nanoid';
|
||||||
listBucketsXml,
|
import { createBucket, deleteBucket, findBucketByName, listBuckets } from '../db/buckets';
|
||||||
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 {
|
import {
|
||||||
|
countBucketObjects,
|
||||||
findFileByBucketAndKey,
|
findFileByBucketAndKey,
|
||||||
listObjectsByPrefix,
|
listObjectsByPrefix,
|
||||||
softDeleteFile,
|
softDeleteFile,
|
||||||
countBucketObjects,
|
|
||||||
} from '../db/files-ext';
|
} from '../db/files-ext';
|
||||||
|
import {
|
||||||
|
abortMultipartUpload,
|
||||||
|
completeMultipartUpload,
|
||||||
|
createMultipartUpload,
|
||||||
|
findMultipartUpload,
|
||||||
|
insertMultipartPart,
|
||||||
|
listMultipartParts,
|
||||||
|
} from '../db/multipart';
|
||||||
import type { File } from '../db/schema';
|
import type { File } from '../db/schema';
|
||||||
import { config } from '../env';
|
import { config } from '../env';
|
||||||
import { forwardToStorage, getFileInfo } from '../utils/telegram';
|
import { cleanupTempFile, computeHash, ensureExtension, getErrorMessage } from '../utils/file';
|
||||||
import { computeHash, ensureExtension, getErrorMessage, cleanupTempFile } from '../utils/file';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import { createReadStream } from 'node:fs';
|
|
||||||
import logger from '../utils/logger';
|
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 REGION = config.s3DefaultRegion || 'us-east-1';
|
||||||
const REQUEST_ID = () => nanoid(16);
|
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 {
|
import {
|
||||||
|
countBucketObjects,
|
||||||
findFileByBucketAndKey,
|
findFileByBucketAndKey,
|
||||||
listObjectsByPrefix,
|
listObjectsByPrefix,
|
||||||
softDeleteFile,
|
softDeleteFile,
|
||||||
countBucketObjects,
|
|
||||||
} from '../db/files-ext';
|
} from '../db/files-ext';
|
||||||
import { createReadStream } from 'node:fs';
|
|
||||||
import { config } from '../env';
|
import { config } from '../env';
|
||||||
import { forwardToStorage, getFileInfo } from '../utils/telegram';
|
import { cleanupTempFile, computeHash, ensureExtension, getErrorMessage } from '../utils/file';
|
||||||
import { computeHash, ensureExtension, getErrorMessage, cleanupTempFile } from '../utils/file';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
import { forwardToStorage, getFileInfo } from '../utils/telegram';
|
||||||
|
|
||||||
type RouteParams = { bucket?: string; key?: string };
|
type RouteParams = { bucket?: string; key?: string };
|
||||||
|
|
||||||
|
|||||||
@@ -131,8 +131,8 @@ export const verifySignature = async (
|
|||||||
s3SecretKey: string,
|
s3SecretKey: string,
|
||||||
region: string,
|
region: string,
|
||||||
): Promise<SigV4Result> => {
|
): Promise<SigV4Result> => {
|
||||||
const authHeader = headers['authorization'];
|
const authHeader = headers.authorization;
|
||||||
if (!authHeader || !authHeader.startsWith('AWS4-HMAC-SHA256')) {
|
if (!authHeader?.startsWith('AWS4-HMAC-SHA256')) {
|
||||||
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
|
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +276,6 @@ export const verifyPresignedUrl = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const isS3Request = (headers: Record<string, string>): boolean => {
|
export const isS3Request = (headers: Record<string, string>): boolean => {
|
||||||
const auth = headers['authorization'] || '';
|
const auth = headers.authorization || '';
|
||||||
return auth.startsWith('AWS4-HMAC-SHA256');
|
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 = (
|
export const listBucketsXml = (
|
||||||
buckets: { name: string; createdAt: Date }[],
|
buckets: { name: string; createdAt: Date }[],
|
||||||
requestId: string,
|
_requestId: string,
|
||||||
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||||
<Buckets>
|
<Buckets>
|
||||||
@@ -39,7 +39,7 @@ export const listBucketResultXml = (
|
|||||||
prefix: string,
|
prefix: string,
|
||||||
delimiter: string | null,
|
delimiter: string | null,
|
||||||
nextMarker: string | null,
|
nextMarker: string | null,
|
||||||
requestId: string,
|
_requestId: string,
|
||||||
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||||
<Name>${escapeXml(bucketName)}</Name>
|
<Name>${escapeXml(bucketName)}</Name>
|
||||||
@@ -80,7 +80,7 @@ export const listBucketV2ResultXml = (
|
|||||||
continuationToken: string | null,
|
continuationToken: string | null,
|
||||||
nextContinuationToken: string | null,
|
nextContinuationToken: string | null,
|
||||||
keyCount: number,
|
keyCount: number,
|
||||||
requestId: string,
|
_requestId: string,
|
||||||
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<ListBucketResultV2 xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
<ListBucketResultV2 xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||||
<Name>${escapeXml(bucketName)}</Name>
|
<Name>${escapeXml(bucketName)}</Name>
|
||||||
@@ -131,7 +131,7 @@ export const listPartsXml = (
|
|||||||
parts: { partNumber: number; etag: string; sizeBytes: number; createdAt: Date }[],
|
parts: { partNumber: number; etag: string; sizeBytes: number; createdAt: Date }[],
|
||||||
maxParts: number,
|
maxParts: number,
|
||||||
isTruncated: boolean,
|
isTruncated: boolean,
|
||||||
requestId: string,
|
_requestId: string,
|
||||||
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<ListPartsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
<ListPartsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||||
<Bucket>${escapeXml(bucketName)}</Bucket>
|
<Bucket>${escapeXml(bucketName)}</Bucket>
|
||||||
@@ -233,12 +233,7 @@ export const s3ErrorResponse = (
|
|||||||
// ─────── DeleteObjects XML parser ───────
|
// ─────── DeleteObjects XML parser ───────
|
||||||
|
|
||||||
export const parseDeleteObjectsBody = (body: string): { keys: string[]; quiet: boolean } => {
|
export const parseDeleteObjectsBody = (body: string): { keys: string[]; quiet: boolean } => {
|
||||||
const keys: string[] = [];
|
const keys = Array.from(body.matchAll(/<Key>([^<]+)<\/Key>/g), (match) => match[1]);
|
||||||
const keyRegex = /<Key>([^<]+)<\/Key>/g;
|
|
||||||
let match;
|
|
||||||
while ((match = keyRegex.exec(body)) !== null) {
|
|
||||||
keys.push(match[1]);
|
|
||||||
}
|
|
||||||
const quiet = body.includes('<Quiet>true</Quiet>') || body.includes('<Quiet>true ');
|
const quiet = body.includes('<Quiet>true</Quiet>') || body.includes('<Quiet>true ');
|
||||||
return { keys, quiet };
|
return { keys, quiet };
|
||||||
};
|
};
|
||||||
|
|||||||
+50
-19
@@ -11,7 +11,7 @@
|
|||||||
* S3_SECRET_KEY=xxx bun test test/production-e2e.test.ts
|
* S3_SECRET_KEY=xxx bun test test/production-e2e.test.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it, afterAll } from 'bun:test';
|
import { afterAll, describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
// ── Config ───────────────────────────────────────────────────────────────────
|
// ── Config ───────────────────────────────────────────────────────────────────
|
||||||
const BASE_URL = process.env.BASE_URL || 'https://upload.asepharyana.my.id';
|
const BASE_URL = process.env.BASE_URL || 'https://upload.asepharyana.my.id';
|
||||||
@@ -26,7 +26,9 @@ let createdBuckets: string[] = [];
|
|||||||
function sha256hex(data: string | Uint8Array): string {
|
function sha256hex(data: string | Uint8Array): string {
|
||||||
const h = new Bun.CryptoHasher('sha256');
|
const h = new Bun.CryptoHasher('sha256');
|
||||||
h.update(data);
|
h.update(data);
|
||||||
return Array.from(h.digest()).map((b) => b.toString(16).padStart(2, '0')).join('');
|
return Array.from(h.digest())
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function hmacSha256(key: Uint8Array, msg: string): Uint8Array {
|
function hmacSha256(key: Uint8Array, msg: string): Uint8Array {
|
||||||
@@ -37,13 +39,16 @@ function hmacSha256(key: Uint8Array, msg: string): Uint8Array {
|
|||||||
|
|
||||||
function getSigningKey(secret: string, ds: string, region: string): Uint8Array {
|
function getSigningKey(secret: string, ds: string, region: string): Uint8Array {
|
||||||
const enc = (s: string) => new TextEncoder().encode(s);
|
const enc = (s: string) => new TextEncoder().encode(s);
|
||||||
let k = hmacSha256(enc('AWS4' + secret), ds);
|
let k = hmacSha256(enc(`AWS4${secret}`), ds);
|
||||||
k = hmacSha256(k, region); k = hmacSha256(k, 's3');
|
k = hmacSha256(k, region);
|
||||||
|
k = hmacSha256(k, 's3');
|
||||||
return hmacSha256(k, 'aws4_request');
|
return hmacSha256(k, 'aws4_request');
|
||||||
}
|
}
|
||||||
|
|
||||||
function hex(a: Uint8Array): string {
|
function hex(a: Uint8Array): string {
|
||||||
return Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');
|
return Array.from(a)
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build S3 SigV4 authorization headers for a raw HTTP request. */
|
/** Build S3 SigV4 authorization headers for a raw HTTP request. */
|
||||||
@@ -79,7 +84,11 @@ function s3Headers(
|
|||||||
async function s3Request(
|
async function s3Request(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
opts: { body?: Uint8Array; query?: Record<string, string>; headers?: Record<string, string> } = {},
|
opts: {
|
||||||
|
body?: Uint8Array;
|
||||||
|
query?: Record<string, string>;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
} = {},
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const url = new URL(path, BASE_URL);
|
const url = new URL(path, BASE_URL);
|
||||||
if (opts.query) {
|
if (opts.query) {
|
||||||
@@ -87,7 +96,14 @@ async function s3Request(
|
|||||||
}
|
}
|
||||||
const rawBody = opts.body ?? new Uint8Array(0);
|
const rawBody = opts.body ?? new Uint8Array(0);
|
||||||
const payloadHash = sha256hex(rawBody);
|
const payloadHash = sha256hex(rawBody);
|
||||||
const headers = s3Headers(method, url.host, url.pathname, url.searchParams.toString(), payloadHash, opts.headers);
|
const headers = s3Headers(
|
||||||
|
method,
|
||||||
|
url.host,
|
||||||
|
url.pathname,
|
||||||
|
url.searchParams.toString(),
|
||||||
|
payloadHash,
|
||||||
|
opts.headers,
|
||||||
|
);
|
||||||
return fetch(url.toString(), {
|
return fetch(url.toString(), {
|
||||||
method,
|
method,
|
||||||
headers: { ...headers, 'Content-Type': 'application/octet-stream' },
|
headers: { ...headers, 'Content-Type': 'application/octet-stream' },
|
||||||
@@ -103,8 +119,11 @@ const apiJson = (p: string, o: RequestInit = {}) =>
|
|||||||
// ── Shared cleanup ───────────────────────────────────────────────────────────
|
// ── Shared cleanup ───────────────────────────────────────────────────────────
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
for (const name of createdBuckets) {
|
for (const name of createdBuckets) {
|
||||||
try { await fetch(`${BASE_URL}/api/v1/buckets/${name}`, { method: 'DELETE' }); }
|
try {
|
||||||
catch { /* best-effort */ }
|
await fetch(`${BASE_URL}/api/v1/buckets/${name}`, { method: 'DELETE' });
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,7 +135,7 @@ describe('Web API v1 (production)', () => {
|
|||||||
it('GET /api/v1/buckets — returns bucket list', async () => {
|
it('GET /api/v1/buckets — returns bucket list', async () => {
|
||||||
const r = await apiJson('/buckets');
|
const r = await apiJson('/buckets');
|
||||||
expect(r.status).toBe(200);
|
expect(r.status).toBe(200);
|
||||||
const b = await r.json() as { buckets: unknown[] };
|
const b = (await r.json()) as { buckets: unknown[] };
|
||||||
expect(Array.isArray(b.buckets)).toBe(true);
|
expect(Array.isArray(b.buckets)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,7 +154,10 @@ describe('Web API v1 (production)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/v1/buckets — rejects invalid name (400)', async () => {
|
it('POST /api/v1/buckets — rejects invalid name (400)', async () => {
|
||||||
const r = await apiJson('/buckets', { method: 'POST', body: JSON.stringify({ name: 'INVALID!' }) });
|
const r = await apiJson('/buckets', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name: 'INVALID!' }),
|
||||||
|
});
|
||||||
expect(r.status).toBe(400);
|
expect(r.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,7 +176,7 @@ describe('Web API v1 (production)', () => {
|
|||||||
it('GET /api/v1/buckets/:name/objects — lists objects', async () => {
|
it('GET /api/v1/buckets/:name/objects — lists objects', async () => {
|
||||||
const r = await apiJson(`/buckets/e2e-web-${TS}/objects`);
|
const r = await apiJson(`/buckets/e2e-web-${TS}/objects`);
|
||||||
expect(r.status).toBe(200);
|
expect(r.status).toBe(200);
|
||||||
const b = await r.json() as { objects: unknown[] };
|
const b = (await r.json()) as { objects: unknown[] };
|
||||||
expect(Array.isArray(b.objects)).toBe(true);
|
expect(Array.isArray(b.objects)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -162,9 +184,12 @@ describe('Web API v1 (production)', () => {
|
|||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', new Blob(['hello']), 'hello.txt');
|
fd.append('file', new Blob(['hello']), 'hello.txt');
|
||||||
fd.append('key', 'hello.txt');
|
fd.append('key', 'hello.txt');
|
||||||
const r = await fetch(`${BASE_URL}/api/v1/buckets/e2e-web-${TS}/upload`, { method: 'POST', body: fd });
|
const r = await fetch(`${BASE_URL}/api/v1/buckets/e2e-web-${TS}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
expect(r.status).toBe(201);
|
expect(r.status).toBe(201);
|
||||||
const b = await r.json() as { key: string; etag: string };
|
const b = (await r.json()) as { key: string; etag: string };
|
||||||
expect(b.key).toBe('hello.txt');
|
expect(b.key).toBe('hello.txt');
|
||||||
expect(b.etag).toBeTruthy();
|
expect(b.etag).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -172,7 +197,7 @@ describe('Web API v1 (production)', () => {
|
|||||||
it('GET /api/v1/buckets/:name/objects — file now present', async () => {
|
it('GET /api/v1/buckets/:name/objects — file now present', async () => {
|
||||||
const r = await apiJson(`/buckets/e2e-web-${TS}/objects`);
|
const r = await apiJson(`/buckets/e2e-web-${TS}/objects`);
|
||||||
expect(r.status).toBe(200);
|
expect(r.status).toBe(200);
|
||||||
const b = await r.json() as { objects: { key: string }[] };
|
const b = (await r.json()) as { objects: { key: string }[] };
|
||||||
expect(b.objects.some((o) => o.key === 'hello.txt')).toBe(true);
|
expect(b.objects.some((o) => o.key === 'hello.txt')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -270,7 +295,9 @@ describe('S3 API (production, SigV4)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ListObjectsV2 — continuation', async () => {
|
it('ListObjectsV2 — continuation', async () => {
|
||||||
const r = await s3Request('GET', `/${bucketName}`, { query: { 'list-type': '2', 'max-keys': '1' } });
|
const r = await s3Request('GET', `/${bucketName}`, {
|
||||||
|
query: { 'list-type': '2', 'max-keys': '1' },
|
||||||
|
});
|
||||||
expect(r.status).toBe(200);
|
expect(r.status).toBe(200);
|
||||||
const xml = await r.text();
|
const xml = await r.text();
|
||||||
expect(xml).toContain('IsTruncated');
|
expect(xml).toContain('IsTruncated');
|
||||||
@@ -285,7 +312,8 @@ describe('S3 API (production, SigV4)', () => {
|
|||||||
const content = new TextEncoder().encode('del');
|
const content = new TextEncoder().encode('del');
|
||||||
await s3Request('PUT', `/${bucketName}/batch-1.txt`, { body: content });
|
await s3Request('PUT', `/${bucketName}/batch-1.txt`, { body: content });
|
||||||
await s3Request('PUT', `/${bucketName}/batch-2.txt`, { body: content });
|
await s3Request('PUT', `/${bucketName}/batch-2.txt`, { body: content });
|
||||||
const deleteBody = '<Delete><Object><Key>batch-1.txt</Key></Object><Object><Key>batch-2.txt</Key></Object></Delete>';
|
const deleteBody =
|
||||||
|
'<Delete><Object><Key>batch-1.txt</Key></Object><Object><Key>batch-2.txt</Key></Object></Delete>';
|
||||||
const r = await s3Request('POST', `/${bucketName}`, {
|
const r = await s3Request('POST', `/${bucketName}`, {
|
||||||
query: { delete: '' },
|
query: { delete: '' },
|
||||||
body: new TextEncoder().encode(deleteBody),
|
body: new TextEncoder().encode(deleteBody),
|
||||||
@@ -326,7 +354,9 @@ describe('S3 API (production, SigV4)', () => {
|
|||||||
});
|
});
|
||||||
// Sort keys to match server's alphabetical sort
|
// Sort keys to match server's alphabetical sort
|
||||||
const sorted = [...sp.entries()].sort(([a], [b]) => a.localeCompare(b));
|
const sorted = [...sp.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||||
const canonicalQs = sorted.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
|
const canonicalQs = sorted
|
||||||
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
||||||
|
.join('&');
|
||||||
|
|
||||||
const canonical = `GET\n/${bucketName}/presigned-test.txt\n${canonicalQs}\nhost:${host}\n\nhost\nUNSIGNED-PAYLOAD`;
|
const canonical = `GET\n/${bucketName}/presigned-test.txt\n${canonicalQs}\nhost:${host}\n\nhost\nUNSIGNED-PAYLOAD`;
|
||||||
const hcr = sha256hex(canonical);
|
const hcr = sha256hex(canonical);
|
||||||
@@ -373,7 +403,8 @@ describe('S3 API (production, SigV4)', () => {
|
|||||||
it('S3 error — bad signature returns 403', async () => {
|
it('S3 error — bad signature returns 403', async () => {
|
||||||
const r = await fetch(`${BASE_URL}/`, {
|
const r = await fetch(`${BASE_URL}/`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: 'AWS4-HMAC-SHA256 Credential=fake/20260701/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=00',
|
Authorization:
|
||||||
|
'AWS4-HMAC-SHA256 Credential=fake/20260701/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=00',
|
||||||
'x-amz-date': '20260701T000000Z',
|
'x-amz-date': '20260701T000000Z',
|
||||||
'x-amz-content-sha256': 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
'x-amz-content-sha256': 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it, beforeAll } from 'bun:test';
|
import { beforeAll, describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
describe('S3 Auth (SigV4)', () => {
|
describe('S3 Auth (SigV4)', () => {
|
||||||
let verifySignature: typeof import('../src/utils/s3/auth').verifySignature;
|
let verifySignature: typeof import('../src/utils/s3/auth').verifySignature;
|
||||||
|
|||||||
+29
-25
@@ -16,25 +16,25 @@
|
|||||||
* CAUTION: creates & destroys real resources on the production server!
|
* CAUTION: creates & destroys real resources on the production server!
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it, afterAll } from 'bun:test';
|
import { afterAll, describe, expect, it } from 'bun:test';
|
||||||
import {
|
import {
|
||||||
S3Client,
|
AbortMultipartUploadCommand,
|
||||||
ListBucketsCommand,
|
|
||||||
CreateBucketCommand,
|
|
||||||
HeadBucketCommand,
|
|
||||||
DeleteBucketCommand,
|
|
||||||
PutObjectCommand,
|
|
||||||
GetObjectCommand,
|
|
||||||
HeadObjectCommand,
|
|
||||||
ListObjectsV2Command,
|
|
||||||
ListObjectsCommand,
|
|
||||||
CopyObjectCommand,
|
CopyObjectCommand,
|
||||||
|
CreateBucketCommand,
|
||||||
|
DeleteBucketCommand,
|
||||||
DeleteObjectCommand,
|
DeleteObjectCommand,
|
||||||
DeleteObjectsCommand,
|
DeleteObjectsCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
HeadBucketCommand,
|
||||||
|
HeadObjectCommand,
|
||||||
|
ListBucketsCommand,
|
||||||
ListMultipartUploadsCommand,
|
ListMultipartUploadsCommand,
|
||||||
AbortMultipartUploadCommand,
|
ListObjectsCommand,
|
||||||
|
ListObjectsV2Command,
|
||||||
NoSuchKey,
|
NoSuchKey,
|
||||||
NotFound,
|
NotFound,
|
||||||
|
PutObjectCommand,
|
||||||
|
S3Client,
|
||||||
} from '@aws-sdk/client-s3';
|
} from '@aws-sdk/client-s3';
|
||||||
|
|
||||||
// ── Config ───────────────────────────────────────────────────────────────────
|
// ── Config ───────────────────────────────────────────────────────────────────
|
||||||
@@ -66,21 +66,29 @@ afterAll(async () => {
|
|||||||
if (keys.length) {
|
if (keys.length) {
|
||||||
await s3.send(new DeleteObjectsCommand({ Bucket: b, Delete: { Objects: keys } }));
|
await s3.send(new DeleteObjectsCommand({ Bucket: b, Delete: { Objects: keys } }));
|
||||||
}
|
}
|
||||||
} catch { /* best-effort */ }
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
// Clean up any multipart uploads
|
// Clean up any multipart uploads
|
||||||
try {
|
try {
|
||||||
const { Uploads = [] } = await s3.send(
|
const { Uploads = [] } = await s3.send(new ListMultipartUploadsCommand({ Bucket: b }));
|
||||||
new ListMultipartUploadsCommand({ Bucket: b }),
|
|
||||||
);
|
|
||||||
for (const u of Uploads) {
|
for (const u of Uploads) {
|
||||||
await s3.send(
|
await s3.send(
|
||||||
new AbortMultipartUploadCommand({
|
new AbortMultipartUploadCommand({
|
||||||
Bucket: b, Key: u.Key!, UploadId: u.UploadId!,
|
Bucket: b,
|
||||||
|
Key: u.Key!,
|
||||||
|
UploadId: u.UploadId!,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch { /* best-effort */ }
|
} catch {
|
||||||
try { await s3.send(new DeleteBucketCommand({ Bucket: b })); } catch { /* best-effort */ }
|
/* best-effort */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await s3.send(new DeleteBucketCommand({ Bucket: b }));
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -234,9 +242,7 @@ describe('S3 SDK compatibility', () => {
|
|||||||
it('DeleteObject removes a single object', async () => {
|
it('DeleteObject removes a single object', async () => {
|
||||||
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'folder/nested-file.txt' }));
|
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'folder/nested-file.txt' }));
|
||||||
// Verify deletion
|
// Verify deletion
|
||||||
const { Contents } = await s3.send(
|
const { Contents } = await s3.send(new ListObjectsV2Command({ Bucket: BUCKET }));
|
||||||
new ListObjectsV2Command({ Bucket: BUCKET }),
|
|
||||||
);
|
|
||||||
expect(Contents!.some((o) => o.Key === 'folder/nested-file.txt')).toBe(false);
|
expect(Contents!.some((o) => o.Key === 'folder/nested-file.txt')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -255,9 +261,7 @@ describe('S3 SDK compatibility', () => {
|
|||||||
expect(Deleted).toHaveLength(3);
|
expect(Deleted).toHaveLength(3);
|
||||||
expect(Errors).toBeUndefined();
|
expect(Errors).toBeUndefined();
|
||||||
// Verify all deleted
|
// Verify all deleted
|
||||||
const { Contents } = await s3.send(
|
const { Contents } = await s3.send(new ListObjectsV2Command({ Bucket: BUCKET }));
|
||||||
new ListObjectsV2Command({ Bucket: BUCKET }),
|
|
||||||
);
|
|
||||||
for (const k of ['del-a.txt', 'del-b.txt', 'del-c.txt']) {
|
for (const k of ['del-a.txt', 'del-b.txt', 'del-c.txt']) {
|
||||||
expect(Contents!.some((o) => o.Key === k)).toBe(false);
|
expect(Contents!.some((o) => o.Key === k)).toBe(false);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user