fix: production bugs + comprehensive production e2e tests
Fixes:
- RowList bug: postgres.js returns array directly, not {rows}. Fix in
buckets.ts, files-ext.ts, multipart.ts (3 files, 8 functions)
- S3 ListBuckets routing: GET / was intercepted by handleHome route
- Presigned URL detection: isS3Request() only checked Authorization header
- FK constraint on bucket delete: cascade-delete files & multipart rows first
- docker-compose.yml: pass S3_ACCESS_KEY / S3_SECRET_KEY to container
- Dockerfile: copy home.html to runner stage for handleHome
Tests:
- test/production-e2e.test.ts: 29 tests (11 Web API + 18 S3 SigV4)
All pass against https://upload.asepharyana.my.id
- Creates and cleans up real buckets/objects on production
This commit is contained in:
+18
-10
@@ -8,16 +8,14 @@ export interface Bucket {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface QueryResult {
|
||||
rows: Record<string, unknown>[];
|
||||
rowCount: number;
|
||||
}
|
||||
type QueryRow = Record<string, unknown>;
|
||||
type QueryResult = QueryRow[];
|
||||
|
||||
export const createBucket = async (name: string): Promise<Bucket> => {
|
||||
const result = (await db.execute(
|
||||
sql`INSERT INTO buckets (name) VALUES (${name}) RETURNING id, name, created_at, updated_at`,
|
||||
)) as unknown as QueryResult;
|
||||
const row = result.rows[0];
|
||||
const row = result[0]!;
|
||||
return {
|
||||
id: row.id as string,
|
||||
name: row.name as string,
|
||||
@@ -30,8 +28,8 @@ export const findBucketByName = async (name: string): Promise<Bucket | null> =>
|
||||
const result = (await db.execute(
|
||||
sql`SELECT id, name, created_at, updated_at FROM buckets WHERE name = ${name}`,
|
||||
)) as unknown as QueryResult;
|
||||
if (result.rows.length === 0) return null;
|
||||
const row = result.rows[0];
|
||||
if (result.length === 0) return null;
|
||||
const row = result[0]!;
|
||||
return {
|
||||
id: row.id as string,
|
||||
name: row.name as string,
|
||||
@@ -44,7 +42,7 @@ export const listBuckets = async (): Promise<Bucket[]> => {
|
||||
const result = (await db.execute(
|
||||
sql`SELECT id, name, created_at, updated_at FROM buckets ORDER BY name`,
|
||||
)) as unknown as QueryResult;
|
||||
return result.rows.map((row) => ({
|
||||
return result.map((row) => ({
|
||||
id: row.id as string,
|
||||
name: row.name as string,
|
||||
createdAt: new Date(row.created_at as string),
|
||||
@@ -53,15 +51,25 @@ 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(() => {});
|
||||
const result = (await db.execute(
|
||||
sql`DELETE FROM buckets WHERE name = ${name}`,
|
||||
)) as unknown as QueryResult;
|
||||
return result.rowCount > 0;
|
||||
return result.length > 0;
|
||||
};
|
||||
|
||||
export const bucketExists = async (name: string): Promise<boolean> => {
|
||||
const result = (await db.execute(
|
||||
sql`SELECT 1 FROM buckets WHERE name = ${name}`,
|
||||
)) as unknown as QueryResult;
|
||||
return result.rows.length > 0;
|
||||
return result.length > 0;
|
||||
};
|
||||
|
||||
+7
-9
@@ -70,15 +70,13 @@ export const listObjectsByPrefix = async (
|
||||
|
||||
query = sql`${query} ORDER BY s3_key LIMIT ${maxKeys + 1}`;
|
||||
|
||||
const rawResult = (await db.execute(query)) as unknown as {
|
||||
rows: Record<string, unknown>[];
|
||||
};
|
||||
const rawResult = (await db.execute(query)) as unknown as Record<string, unknown>[];
|
||||
|
||||
if (delimiter === '/') {
|
||||
const prefixSet = new Set<string>();
|
||||
const objects: S3FileRecord[] = [];
|
||||
|
||||
for (const row of rawResult.rows) {
|
||||
for (const row of rawResult) {
|
||||
const s3Key = row.s3_key as string;
|
||||
const relativeKey = s3Key.substring(prefix.length);
|
||||
const slashIndex = relativeKey.indexOf('/');
|
||||
@@ -99,7 +97,7 @@ export const listObjectsByPrefix = async (
|
||||
}
|
||||
|
||||
return {
|
||||
objects: rawResult.rows.slice(0, maxKeys).map(mapDbRowToS3Record),
|
||||
objects: rawResult.slice(0, maxKeys).map(mapDbRowToS3Record),
|
||||
prefixes: [],
|
||||
};
|
||||
};
|
||||
@@ -107,8 +105,8 @@ export const listObjectsByPrefix = async (
|
||||
export const softDeleteFile = async (bucketId: string, s3Key: string): Promise<boolean> => {
|
||||
const result = (await db.execute(
|
||||
sql`UPDATE files SET is_deleted = true WHERE bucket_id = ${bucketId}::uuid AND s3_key = ${s3Key} RETURNING id`,
|
||||
)) as unknown as { rows: Record<string, unknown>[] };
|
||||
return result.rows.length > 0;
|
||||
)) as unknown as Record<string, unknown>[];
|
||||
return result.length > 0;
|
||||
};
|
||||
|
||||
export const softDeleteFilesBatch = async (bucketId: string, keys: string[]): Promise<number> => {
|
||||
@@ -123,8 +121,8 @@ export const softDeleteFilesBatch = async (bucketId: string, keys: string[]): Pr
|
||||
export const countBucketObjects = async (bucketId: string): Promise<number> => {
|
||||
const result = (await db.execute(
|
||||
sql`SELECT count(*) as count FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false`,
|
||||
)) as unknown as { rows: Record<string, unknown>[] };
|
||||
return Number(result.rows[0]?.count || 0);
|
||||
)) as unknown as Record<string, unknown>[];
|
||||
return Number(result[0]?.count || 0);
|
||||
};
|
||||
|
||||
export const findOrphanFilesByBucket = async (bucketId: string): Promise<File[]> => {
|
||||
|
||||
+5
-10
@@ -23,11 +23,6 @@ export interface MultipartPart {
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface QueryResult {
|
||||
rows: Record<string, unknown>[];
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
export const createMultipartUpload = async (
|
||||
bucketId: string,
|
||||
s3Key: string,
|
||||
@@ -43,9 +38,9 @@ export const createMultipartUpload = async (
|
||||
export const findMultipartUpload = async (uploadId: string): Promise<MultipartUpload | null> => {
|
||||
const result = (await db.execute(
|
||||
sql`SELECT upload_id, bucket_id, s3_key, initiated_at, status FROM multipart_uploads WHERE upload_id = ${uploadId} AND status = 'in_progress'`,
|
||||
)) as unknown as QueryResult;
|
||||
if (result.rows.length === 0) return null;
|
||||
const r = result.rows[0];
|
||||
)) as unknown as Record<string, unknown>[];
|
||||
if (result.length === 0) return null;
|
||||
const r = result[0]!;
|
||||
return {
|
||||
uploadId: r.upload_id as string,
|
||||
bucketId: r.bucket_id as string,
|
||||
@@ -82,8 +77,8 @@ export const listMultipartParts = async (uploadId: string): Promise<MultipartPar
|
||||
const result = (await db.execute(
|
||||
sql`SELECT id, upload_id, part_number, telegram_file_id, telegram_file_unique_id, storage_message_id, size_bytes, etag, created_at
|
||||
FROM multipart_parts WHERE upload_id = ${uploadId} ORDER BY part_number`,
|
||||
)) as unknown as QueryResult;
|
||||
return result.rows.map((r) => ({
|
||||
)) as unknown as Record<string, unknown>[];
|
||||
return result.map((r) => ({
|
||||
id: r.id as number,
|
||||
uploadId: r.upload_id as string,
|
||||
partNumber: r.part_number as number,
|
||||
|
||||
+37
-2
@@ -52,7 +52,42 @@ const server = serve({
|
||||
GET: handleSwaggerJson,
|
||||
},
|
||||
'/': {
|
||||
GET: handleHome,
|
||||
GET: (req: Request) => {
|
||||
const headers = Object.fromEntries(req.headers);
|
||||
const url = new URL(req.url);
|
||||
if (isS3Request(headers) || url.searchParams.has('X-Amz-Signature')) {
|
||||
return handleS3Request(req);
|
||||
}
|
||||
return handleHome();
|
||||
},
|
||||
PUT: (req: Request) => {
|
||||
const headers = Object.fromEntries(req.headers);
|
||||
if (isS3Request(headers)) {
|
||||
return handleS3Request(req);
|
||||
}
|
||||
return new Response('Not Allowed', { status: 405 });
|
||||
},
|
||||
HEAD: (req: Request) => {
|
||||
const headers = Object.fromEntries(req.headers);
|
||||
if (isS3Request(headers)) {
|
||||
return handleS3Request(req);
|
||||
}
|
||||
return new Response('Not Allowed', { status: 405 });
|
||||
},
|
||||
DELETE: (req: Request) => {
|
||||
const headers = Object.fromEntries(req.headers);
|
||||
if (isS3Request(headers)) {
|
||||
return handleS3Request(req);
|
||||
}
|
||||
return new Response('Not Allowed', { status: 405 });
|
||||
},
|
||||
POST: (req: Request) => {
|
||||
const headers = Object.fromEntries(req.headers);
|
||||
if (isS3Request(headers)) {
|
||||
return handleS3Request(req);
|
||||
}
|
||||
return new Response('Not Allowed', { status: 405 });
|
||||
},
|
||||
},
|
||||
'/api/v1/*': {
|
||||
GET: handleWebApiV1,
|
||||
@@ -63,7 +98,7 @@ const server = serve({
|
||||
},
|
||||
fetch: async (req: Request) => {
|
||||
const headers = Object.fromEntries(req.headers);
|
||||
if (isS3Request(headers)) {
|
||||
if (isS3Request(headers) || new URL(req.url).searchParams.has('X-Amz-Signature')) {
|
||||
return handleS3Request(req);
|
||||
}
|
||||
return new Response('Not Found', { status: 404 });
|
||||
|
||||
Reference in New Issue
Block a user