1352 lines
42 KiB
Markdown
1352 lines
42 KiB
Markdown
# S3 Compatibility Completion Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Finish TeleUploader S3 compatibility gaps: strict presigned GET, byte ranges, complete multipart GetObject streaming, strict AWS SDK multipart investigation/fix, and warning-free lint.
|
|
|
|
**Architecture:** Add focused S3 utility helpers for byte-range parsing and object streaming, then wire `src/routes/s3.ts` through those helpers. Keep SigV4 verification strict; fix canonicalization errors rather than adding auth fallback. Tests are layered: unit tests for pure helpers, production E2E for protocol behavior, AWS SDK E2E for real-client compatibility.
|
|
|
|
**Tech Stack:** Bun 1.3, Bun test, Bun `fetch`/Web Streams, Drizzle/Postgres, Telegram CDN via `getFileInfo`, AWS SDK v3 S3 client.
|
|
|
|
## Global Constraints
|
|
|
|
- Use Bun commands: `bun test`, `bun run`, `bunx`; do not use npm/yarn/pnpm/node.
|
|
- Preserve strict SigV4 verification; do not accept invalid signatures as a compatibility fallback.
|
|
- Production endpoint remains `https://upload.asepharyana.my.id` by default in E2E tests.
|
|
- `PROXY_S3_GET=true` remains the production-compatible default.
|
|
- S3 path-style addressing remains required; virtual-hosted style is out of scope.
|
|
- Telegram remains the storage backend; do not introduce a new object store.
|
|
- Every production-impacting task must be verified with targeted tests before commit.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
- Create `src/utils/s3/range.ts`
|
|
- Pure helper for parsing HTTP `Range` headers and generating `Content-Range` values.
|
|
- Create `src/utils/s3/object-stream.ts`
|
|
- Converts file rows and multipart parts into ordered Telegram part sources.
|
|
- Fetches Telegram CDN objects, optionally with ranges.
|
|
- Concatenates part streams and builds S3 `GetObject` responses.
|
|
- Modify `src/utils/s3/auth.ts`
|
|
- Replace presigned URL verifier with object-parameter API that receives original request URL and headers.
|
|
- Fix canonical query/host handling while preserving strict SigV4.
|
|
- Modify `src/routes/s3.ts`
|
|
- Pass request headers/URL into `handleGetObject`.
|
|
- Use shared streaming helper for single-part and multipart `GetObject`.
|
|
- Return `416 InvalidRange` for bad byte ranges.
|
|
- Modify `src/home.html`
|
|
- Reorder CSS selectors to remove the remaining Biome specificity warning.
|
|
- Modify `test/s3-auth.test.ts`
|
|
- Add strict presigned URL tests and extra signed-header SigV4 tests.
|
|
- Create `test/s3-range.test.ts`
|
|
- Unit tests for byte range parser.
|
|
- Create `test/s3-object-stream.test.ts`
|
|
- Unit tests for stream concatenation/range slicing with mocked fetch.
|
|
- Modify `test/production-e2e.test.ts`
|
|
- Presigned `GET` must return `200` body.
|
|
- Add single-part range, invalid range, multipart full body, and multipart cross-part range tests.
|
|
- Modify `test/s3-sdk.test.ts`
|
|
- Add `GetObjectCommand` range test.
|
|
- Replace placeholder multipart test with real AWS SDK multipart flow if strict SigV4 passes.
|
|
|
|
---
|
|
|
|
### Task 1: Add HTTP Range Parser
|
|
|
|
**Files:**
|
|
- Create: `src/utils/s3/range.ts`
|
|
- Create: `test/s3-range.test.ts`
|
|
|
|
**Interfaces:**
|
|
- Produces:
|
|
- `type RangeParseResult`
|
|
- `parseRangeHeader(rangeHeader: string | null, size: number): RangeParseResult`
|
|
- `contentRange(start: number, end: number, size: number): string`
|
|
- `unsatisfiedContentRange(size: number): string`
|
|
- Consumes: none
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `test/s3-range.test.ts`:
|
|
|
|
```ts
|
|
import { describe, expect, it } from 'bun:test';
|
|
import { contentRange, parseRangeHeader, unsatisfiedContentRange } from '../src/utils/s3/range';
|
|
|
|
describe('S3 HTTP range parser', () => {
|
|
it('returns none when Range is missing', () => {
|
|
expect(parseRangeHeader(null, 10)).toEqual({ type: 'none' });
|
|
});
|
|
|
|
it('parses explicit start/end ranges', () => {
|
|
expect(parseRangeHeader('bytes=2-5', 10)).toEqual({ type: 'valid', start: 2, end: 5 });
|
|
});
|
|
|
|
it('clamps open-ended ranges to object size', () => {
|
|
expect(parseRangeHeader('bytes=7-', 10)).toEqual({ type: 'valid', start: 7, end: 9 });
|
|
});
|
|
|
|
it('parses suffix ranges', () => {
|
|
expect(parseRangeHeader('bytes=-4', 10)).toEqual({ type: 'valid', start: 6, end: 9 });
|
|
});
|
|
|
|
it('clamps oversized suffix ranges to the whole object', () => {
|
|
expect(parseRangeHeader('bytes=-50', 10)).toEqual({ type: 'valid', start: 0, end: 9 });
|
|
});
|
|
|
|
it('rejects multiple ranges', () => {
|
|
expect(parseRangeHeader('bytes=0-1,3-4', 10)).toEqual({ type: 'invalid' });
|
|
});
|
|
|
|
it('rejects unsatisfiable ranges', () => {
|
|
expect(parseRangeHeader('bytes=10-12', 10)).toEqual({ type: 'invalid' });
|
|
expect(parseRangeHeader('bytes=6-3', 10)).toEqual({ type: 'invalid' });
|
|
expect(parseRangeHeader('bytes=-0', 10)).toEqual({ type: 'invalid' });
|
|
});
|
|
|
|
it('formats content-range headers', () => {
|
|
expect(contentRange(2, 5, 10)).toBe('bytes 2-5/10');
|
|
expect(unsatisfiedContentRange(10)).toBe('bytes */10');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test and verify it fails**
|
|
|
|
Run: `bun test test/s3-range.test.ts`
|
|
|
|
Expected: FAIL with an import/module-not-found error for `../src/utils/s3/range`.
|
|
|
|
- [ ] **Step 3: Implement `src/utils/s3/range.ts`**
|
|
|
|
```ts
|
|
export type RangeParseResult =
|
|
| { type: 'none' }
|
|
| { type: 'valid'; start: number; end: number }
|
|
| { type: 'invalid' };
|
|
|
|
const DECIMAL = /^\d+$/;
|
|
|
|
export const parseRangeHeader = (rangeHeader: string | null, size: number): RangeParseResult => {
|
|
if (!rangeHeader) return { type: 'none' };
|
|
if (!Number.isSafeInteger(size) || size < 0) return { type: 'invalid' };
|
|
if (!rangeHeader.startsWith('bytes=')) return { type: 'invalid' };
|
|
|
|
const spec = rangeHeader.slice('bytes='.length).trim();
|
|
if (spec.includes(',')) return { type: 'invalid' };
|
|
|
|
const dash = spec.indexOf('-');
|
|
if (dash === -1) return { type: 'invalid' };
|
|
|
|
const startText = spec.slice(0, dash).trim();
|
|
const endText = spec.slice(dash + 1).trim();
|
|
if (!startText && !endText) return { type: 'invalid' };
|
|
if (size === 0) return { type: 'invalid' };
|
|
|
|
if (!startText) {
|
|
if (!DECIMAL.test(endText)) return { type: 'invalid' };
|
|
const suffixLength = Number.parseInt(endText, 10);
|
|
if (suffixLength <= 0) return { type: 'invalid' };
|
|
return { type: 'valid', start: Math.max(size - suffixLength, 0), end: size - 1 };
|
|
}
|
|
|
|
if (!DECIMAL.test(startText)) return { type: 'invalid' };
|
|
const start = Number.parseInt(startText, 10);
|
|
if (start >= size) return { type: 'invalid' };
|
|
|
|
if (!endText) return { type: 'valid', start, end: size - 1 };
|
|
if (!DECIMAL.test(endText)) return { type: 'invalid' };
|
|
|
|
const requestedEnd = Number.parseInt(endText, 10);
|
|
if (requestedEnd < start) return { type: 'invalid' };
|
|
return { type: 'valid', start, end: Math.min(requestedEnd, size - 1) };
|
|
};
|
|
|
|
export const contentRange = (start: number, end: number, size: number): string =>
|
|
`bytes ${start}-${end}/${size}`;
|
|
|
|
export const unsatisfiedContentRange = (size: number): string => `bytes */${size}`;
|
|
```
|
|
|
|
- [ ] **Step 4: Run the test and verify it passes**
|
|
|
|
Run: `bun test test/s3-range.test.ts`
|
|
|
|
Expected: `8 pass`, `0 fail`.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/utils/s3/range.ts test/s3-range.test.ts
|
|
git commit -m "feat: add S3 byte range parser"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Fix Strict Presigned URL Verification
|
|
|
|
**Files:**
|
|
- Modify: `src/utils/s3/auth.ts:194-276`
|
|
- Modify: `src/routes/s3.ts:263-287`
|
|
- Modify: `test/s3-auth.test.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: existing `SigV4Result`
|
|
- Produces:
|
|
- `interface VerifyPresignedUrlInput`
|
|
- `verifyPresignedUrl(input: VerifyPresignedUrlInput): Promise<SigV4Result>`
|
|
- `buildCanonicalQueryString(searchParams: URLSearchParams, excludeKeys?: Set<string>): string` exported for tests
|
|
|
|
- [ ] **Step 1: Add failing presigned tests**
|
|
|
|
Append to `test/s3-auth.test.ts`:
|
|
|
|
```ts
|
|
const sha256hex = (data: string): string => {
|
|
const h = new Bun.CryptoHasher('sha256');
|
|
h.update(data);
|
|
return Array.from(h.digest()).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
};
|
|
|
|
const hmacSha256 = (key: Uint8Array, msg: string): Uint8Array => {
|
|
const h = new Bun.CryptoHasher('sha256', key);
|
|
h.update(msg);
|
|
return h.digest();
|
|
};
|
|
|
|
const signingKey = (secret: string, date: string, region: string): Uint8Array => {
|
|
const enc = (s: string) => new TextEncoder().encode(s);
|
|
let k = hmacSha256(enc(`AWS4${secret}`), date);
|
|
k = hmacSha256(k, region);
|
|
k = hmacSha256(k, 's3');
|
|
return hmacSha256(k, 'aws4_request');
|
|
};
|
|
|
|
const hex = (bytes: Uint8Array): string =>
|
|
Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
|
|
it('verifies presigned GET using the public request host', async () => {
|
|
const accessKey = 'teleuploader-admin';
|
|
const secret = 'unit-test-secret';
|
|
const host = 'upload.example.test';
|
|
const path = '/bucket/key.txt';
|
|
const amzDate = '20260707T120000Z';
|
|
const dateStamp = '20260707';
|
|
const sp = new URLSearchParams({
|
|
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
|
|
'X-Amz-Credential': `${accessKey}/${dateStamp}/us-east-1/s3/aws4_request`,
|
|
'X-Amz-Date': amzDate,
|
|
'X-Amz-Expires': '3600',
|
|
'X-Amz-SignedHeaders': 'host',
|
|
});
|
|
const canonicalQs = [...sp.entries()]
|
|
.sort(([aKey, aVal], [bKey, bVal]) => `${aKey}=${aVal}`.localeCompare(`${bKey}=${bVal}`))
|
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
.join('&');
|
|
const canonicalRequest = `GET\n${path}\n${canonicalQs}\nhost:${host}\n\nhost\nUNSIGNED-PAYLOAD`;
|
|
const hashedCanonical = sha256hex(canonicalRequest);
|
|
const scope = `${dateStamp}/us-east-1/s3/aws4_request`;
|
|
const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${scope}\n${hashedCanonical}`;
|
|
const sig = hex(hmacSha256(signingKey(secret, dateStamp, 'us-east-1'), stringToSign));
|
|
sp.set('X-Amz-Signature', sig);
|
|
|
|
const result = await verifyPresignedUrl({
|
|
url: `https://${host}${path}?${sp.toString()}`,
|
|
method: 'GET',
|
|
headers: { host },
|
|
s3AccessKey: accessKey,
|
|
s3SecretKey: secret,
|
|
region: 'us-east-1',
|
|
now: new Date('2026-07-07T12:05:00Z'),
|
|
});
|
|
|
|
expect(result.isValid).toBe(true);
|
|
});
|
|
|
|
it('rejects presigned URLs signed for a different host', async () => {
|
|
const result = await verifyPresignedUrl({
|
|
url: 'https://wrong.example.test/bucket/key.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=teleuploader-admin%2F20260707%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260707T120000Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=00',
|
|
method: 'GET',
|
|
headers: { host: 'upload.example.test' },
|
|
s3AccessKey: 'teleuploader-admin',
|
|
s3SecretKey: 'unit-test-secret',
|
|
region: 'us-east-1',
|
|
now: new Date('2026-07-07T12:05:00Z'),
|
|
});
|
|
|
|
expect(result.isValid).toBe(false);
|
|
expect(result.errorCode).toBe('SignatureDoesNotMatch');
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the auth tests and verify they fail**
|
|
|
|
Run: `bun test test/s3-auth.test.ts`
|
|
|
|
Expected: FAIL because `verifyPresignedUrl` does not accept an object parameter yet.
|
|
|
|
- [ ] **Step 3: Modify `src/utils/s3/auth.ts`**
|
|
|
|
Add this interface near `SigV4Result`:
|
|
|
|
```ts
|
|
export interface VerifyPresignedUrlInput {
|
|
url: string;
|
|
method: string;
|
|
headers: Record<string, string>;
|
|
s3AccessKey: string;
|
|
s3SecretKey: string;
|
|
region: string;
|
|
now?: Date;
|
|
}
|
|
```
|
|
|
|
Replace the private `buildCanonicalQueryString` with an exported version:
|
|
|
|
```ts
|
|
const awsEncode = (value: string): string =>
|
|
encodeURIComponent(value).replace(/[!'()*]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
|
|
export const buildCanonicalQueryString = (
|
|
searchParams: URLSearchParams,
|
|
excludeKeys: Set<string> = new Set(),
|
|
): string => {
|
|
const pairs: Array<[string, string]> = [];
|
|
for (const [key, value] of searchParams.entries()) {
|
|
if (!excludeKeys.has(key)) pairs.push([key, value]);
|
|
}
|
|
pairs.sort(([ak, av], [bk, bv]) => {
|
|
const a = `${awsEncode(ak)}=${awsEncode(av)}`;
|
|
const b = `${awsEncode(bk)}=${awsEncode(bv)}`;
|
|
return a.localeCompare(b);
|
|
});
|
|
return pairs.map(([key, value]) => `${awsEncode(key)}=${awsEncode(value)}`).join('&');
|
|
};
|
|
```
|
|
|
|
Replace `verifyPresignedUrl` with:
|
|
|
|
```ts
|
|
const parseAmzDateUtc = (amzDate: string): Date | null => {
|
|
const match = amzDate.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/);
|
|
if (!match) return null;
|
|
const [, year, month, day, hour, minute, second] = match;
|
|
return new Date(Date.UTC(
|
|
Number.parseInt(year, 10),
|
|
Number.parseInt(month, 10) - 1,
|
|
Number.parseInt(day, 10),
|
|
Number.parseInt(hour, 10),
|
|
Number.parseInt(minute, 10),
|
|
Number.parseInt(second, 10),
|
|
));
|
|
};
|
|
|
|
export const verifyPresignedUrl = async ({
|
|
url,
|
|
method,
|
|
headers,
|
|
s3AccessKey,
|
|
s3SecretKey,
|
|
region,
|
|
now = new Date(),
|
|
}: VerifyPresignedUrlInput): Promise<SigV4Result> => {
|
|
const parsedUrl = new URL(url);
|
|
const searchParams = parsedUrl.searchParams;
|
|
|
|
const algorithm = searchParams.get('X-Amz-Algorithm');
|
|
const credential = searchParams.get('X-Amz-Credential');
|
|
const signedHeaders = searchParams.get('X-Amz-SignedHeaders');
|
|
const signature = searchParams.get('X-Amz-Signature');
|
|
const expiresText = searchParams.get('X-Amz-Expires');
|
|
const amzDate = searchParams.get('X-Amz-Date');
|
|
|
|
if (algorithm !== 'AWS4-HMAC-SHA256' || !credential || !signedHeaders || !signature || !expiresText || !amzDate) {
|
|
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
|
|
}
|
|
|
|
const expires = Number.parseInt(expiresText, 10);
|
|
const signedAt = parseAmzDateUtc(amzDate);
|
|
if (!Number.isFinite(expires) || expires <= 0 || !signedAt) {
|
|
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
|
|
}
|
|
if (now.getTime() > signedAt.getTime() + expires * 1000) {
|
|
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
|
|
}
|
|
|
|
const credParts = credential.split('/');
|
|
if (credParts.length !== 5) {
|
|
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
|
|
}
|
|
const [accessKey, dateStamp, credentialRegion, service, termination] = credParts;
|
|
if (accessKey !== s3AccessKey || credentialRegion !== region || service !== SERVICE || termination !== TERMINATION) {
|
|
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
|
|
}
|
|
|
|
const signedHeaderList = signedHeaders.split(';').filter(Boolean);
|
|
const canonicalHeaders = signedHeaderList
|
|
.map((headerName) => {
|
|
const lower = headerName.toLowerCase();
|
|
const value = lower === 'host' ? headers.host || parsedUrl.host : headers[lower] || '';
|
|
return `${lower}:${value.trim()}\n`;
|
|
})
|
|
.join('');
|
|
|
|
const canonicalRequest = `${method}\n${normalizeUri(parsedUrl.pathname)}\n${buildCanonicalQueryString(searchParams, new Set(['X-Amz-Signature']))}\n${canonicalHeaders}\n${signedHeaders}\nUNSIGNED-PAYLOAD`;
|
|
const hashedCanonicalRequest = await sha256Hex(canonicalRequest);
|
|
const credentialScope = `${dateStamp}/${region}/${SERVICE}/${TERMINATION}`;
|
|
const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${hashedCanonicalRequest}`;
|
|
const expectedSignature = await hmacHex(await getSigningKey(s3SecretKey, dateStamp, region), stringToSign);
|
|
|
|
if (expectedSignature !== signature) {
|
|
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
|
|
}
|
|
return { isValid: true, credential: { accessKey, date: dateStamp, region, service } };
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 4: Modify `src/routes/s3.ts` to pass original request details**
|
|
|
|
Change the object GET dispatch:
|
|
|
|
```ts
|
|
if (method === 'GET') return handleGetObject(bucket, key, searchParams, headers, req.url, reqId);
|
|
```
|
|
|
|
Change the function signature:
|
|
|
|
```ts
|
|
const handleGetObject = async (
|
|
bucket: string,
|
|
key: string,
|
|
searchParams: URLSearchParams,
|
|
headers: Record<string, string>,
|
|
requestUrl: string,
|
|
reqId: string,
|
|
): Promise<Response> => {
|
|
```
|
|
|
|
Replace the presigned block with:
|
|
|
|
```ts
|
|
if (searchParams.has('X-Amz-Signature')) {
|
|
const presignedResult = await verifyPresignedUrl({
|
|
url: requestUrl,
|
|
method: 'GET',
|
|
headers,
|
|
s3AccessKey: config.s3AccessKey,
|
|
s3SecretKey: config.s3SecretKey,
|
|
region: REGION,
|
|
});
|
|
if (!presignedResult.isValid) {
|
|
return s3ErrorResponse(
|
|
presignedResult.errorCode || 'AccessDenied',
|
|
'Presigned URL verification failed',
|
|
`/${bucket}/${key}`,
|
|
403,
|
|
reqId,
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests and verify they pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
bun test test/s3-auth.test.ts
|
|
bun run lint
|
|
```
|
|
|
|
Expected:
|
|
- `test/s3-auth.test.ts`: all tests pass.
|
|
- `bun run lint`: no errors. A CSS warning may remain until Task 7.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/utils/s3/auth.ts src/routes/s3.ts test/s3-auth.test.ts
|
|
git commit -m "fix: verify presigned S3 URLs against public host"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Add Object Streaming Helper
|
|
|
|
**Files:**
|
|
- Create: `src/utils/s3/object-stream.ts`
|
|
- Create: `test/s3-object-stream.test.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes:
|
|
- `parseRangeHeader()` and `RangeParseResult` from Task 1
|
|
- `getFileInfo(telegramFileId)` from `src/utils/telegram.ts`
|
|
- Produces:
|
|
- `interface ObjectPartSource`
|
|
- `interface ObjectResponseInput`
|
|
- `createGetObjectResponse(input: ObjectResponseInput): Promise<Response>`
|
|
|
|
- [ ] **Step 1: Write the failing stream tests**
|
|
|
|
Create `test/s3-object-stream.test.ts`:
|
|
|
|
```ts
|
|
import { afterEach, describe, expect, it } from 'bun:test';
|
|
import { createGetObjectResponse } from '../src/utils/s3/object-stream';
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
const streamText = (text: string) => new Response(text).body!;
|
|
|
|
const installFetch = () => {
|
|
globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
|
|
const range = new Headers(init?.headers).get('range');
|
|
const url = String(_url);
|
|
const text = url.includes('part-1') ? 'hello ' : 'world';
|
|
if (range === 'bytes=1-3') {
|
|
return new Response(text.slice(1, 4), {
|
|
status: 206,
|
|
headers: { 'content-range': `bytes 1-3/${text.length}`, 'content-length': '3' },
|
|
});
|
|
}
|
|
return new Response(streamText(text), { status: 200, headers: { 'content-length': String(text.length) } });
|
|
}) as typeof fetch;
|
|
};
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
describe('S3 object stream response builder', () => {
|
|
it('concatenates multiple Telegram part streams', async () => {
|
|
installFetch();
|
|
const res = await createGetObjectResponse({
|
|
reqId: 'req-1',
|
|
contentType: 'text/plain',
|
|
etag: 'etag123',
|
|
lastModified: new Date('2026-07-07T00:00:00Z'),
|
|
totalSize: 11,
|
|
parts: [
|
|
{ telegramFileId: 'part-1', telegramUrl: 'https://telegram.test/part-1', sizeBytes: 6, partNumber: 1 },
|
|
{ telegramFileId: 'part-2', telegramUrl: 'https://telegram.test/part-2', sizeBytes: 5, partNumber: 2 },
|
|
],
|
|
range: { type: 'none' },
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('content-length')).toBe('11');
|
|
expect(await res.text()).toBe('hello world');
|
|
});
|
|
|
|
it('returns 206 with content-range for a single-part byte range', async () => {
|
|
installFetch();
|
|
const res = await createGetObjectResponse({
|
|
reqId: 'req-2',
|
|
contentType: 'text/plain',
|
|
etag: 'etag123',
|
|
lastModified: new Date('2026-07-07T00:00:00Z'),
|
|
totalSize: 6,
|
|
parts: [{ telegramFileId: 'part-1', telegramUrl: 'https://telegram.test/part-1', sizeBytes: 6, partNumber: 1 }],
|
|
range: { type: 'valid', start: 1, end: 3 },
|
|
});
|
|
|
|
expect(res.status).toBe(206);
|
|
expect(res.headers.get('content-range')).toBe('bytes 1-3/6');
|
|
expect(res.headers.get('content-length')).toBe('3');
|
|
expect(await res.text()).toBe('ell');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test and verify it fails**
|
|
|
|
Run: `bun test test/s3-object-stream.test.ts`
|
|
|
|
Expected: FAIL with module-not-found for `src/utils/s3/object-stream`.
|
|
|
|
- [ ] **Step 3: Implement `src/utils/s3/object-stream.ts`**
|
|
|
|
```ts
|
|
import { contentRange, type RangeParseResult } from './range';
|
|
|
|
export interface ObjectPartSource {
|
|
telegramFileId: string;
|
|
telegramUrl: string;
|
|
sizeBytes: number;
|
|
partNumber: number;
|
|
}
|
|
|
|
export interface ObjectResponseInput {
|
|
reqId: string;
|
|
contentType: string;
|
|
etag: string;
|
|
lastModified: Date;
|
|
totalSize: number;
|
|
parts: ObjectPartSource[];
|
|
range: RangeParseResult;
|
|
}
|
|
|
|
interface PlannedPart {
|
|
part: ObjectPartSource;
|
|
relativeStart: number;
|
|
relativeEnd: number;
|
|
}
|
|
|
|
const baseHeaders = (input: ObjectResponseInput, contentLength: number): Headers => {
|
|
const headers = new Headers({
|
|
'content-type': input.contentType,
|
|
'content-length': String(contentLength),
|
|
etag: `"${input.etag}"`,
|
|
'last-modified': input.lastModified.toUTCString(),
|
|
'x-amz-request-id': input.reqId,
|
|
'accept-ranges': 'bytes',
|
|
'cache-control': 'public, max-age=31536000',
|
|
});
|
|
return headers;
|
|
};
|
|
|
|
const planParts = (parts: ObjectPartSource[], start: number, end: number): PlannedPart[] => {
|
|
const planned: PlannedPart[] = [];
|
|
let offset = 0;
|
|
for (const part of parts) {
|
|
const partStart = offset;
|
|
const partEnd = offset + part.sizeBytes - 1;
|
|
offset += part.sizeBytes;
|
|
if (end < partStart || start > partEnd) continue;
|
|
planned.push({
|
|
part,
|
|
relativeStart: Math.max(start, partStart) - partStart,
|
|
relativeEnd: Math.min(end, partEnd) - partStart,
|
|
});
|
|
}
|
|
return planned;
|
|
};
|
|
|
|
const fetchPartBody = async (planned: PlannedPart): Promise<ReadableStream<Uint8Array>> => {
|
|
const rangeHeader = `bytes=${planned.relativeStart}-${planned.relativeEnd}`;
|
|
const wantsWholePart = planned.relativeStart === 0 && planned.relativeEnd === planned.part.sizeBytes - 1;
|
|
const res = await fetch(planned.part.telegramUrl, wantsWholePart ? undefined : { headers: { range: rangeHeader } });
|
|
if (!res.ok) throw new Error(`Telegram fetch failed: ${res.status}`);
|
|
if (wantsWholePart || res.status === 206) return res.body!;
|
|
|
|
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
return new Response(bytes.slice(planned.relativeStart, planned.relativeEnd + 1)).body!;
|
|
};
|
|
|
|
const concatPartStreams = (plannedParts: PlannedPart[]): ReadableStream<Uint8Array> =>
|
|
new ReadableStream<Uint8Array>({
|
|
async start(controller) {
|
|
try {
|
|
for (const planned of plannedParts) {
|
|
const stream = await fetchPartBody(planned);
|
|
const reader = stream.getReader();
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
if (value) controller.enqueue(value);
|
|
}
|
|
}
|
|
controller.close();
|
|
} catch (error) {
|
|
controller.error(error);
|
|
}
|
|
},
|
|
});
|
|
|
|
export const createGetObjectResponse = async (input: ObjectResponseInput): Promise<Response> => {
|
|
if (input.range.type === 'invalid') {
|
|
throw new Error('createGetObjectResponse received invalid range');
|
|
}
|
|
|
|
const start = input.range.type === 'valid' ? input.range.start : 0;
|
|
const end = input.range.type === 'valid' ? input.range.end : input.totalSize - 1;
|
|
const plannedParts = planParts(input.parts, start, end);
|
|
const contentLength = end >= start ? end - start + 1 : 0;
|
|
const headers = baseHeaders(input, contentLength);
|
|
|
|
if (input.range.type === 'valid') {
|
|
headers.set('content-range', contentRange(start, end, input.totalSize));
|
|
}
|
|
|
|
return new Response(concatPartStreams(plannedParts), {
|
|
status: input.range.type === 'valid' ? 206 : 200,
|
|
headers,
|
|
});
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 4: Run the object stream tests**
|
|
|
|
Run: `bun test test/s3-object-stream.test.ts`
|
|
|
|
Expected: `2 pass`, `0 fail`.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/utils/s3/object-stream.ts test/s3-object-stream.test.ts
|
|
git commit -m "feat: stream S3 object bodies from Telegram parts"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Wire Single-Part GetObject, Range, and Presigned Production E2E
|
|
|
|
**Files:**
|
|
- Modify: `src/routes/s3.ts:263-417`
|
|
- Modify: `test/production-e2e.test.ts:264-380`
|
|
- Modify: `test/s3-sdk.test.ts:166-175`
|
|
|
|
**Interfaces:**
|
|
- Consumes:
|
|
- `parseRangeHeader()` from Task 1
|
|
- `createGetObjectResponse()` and `ObjectPartSource` from Task 3
|
|
- `verifyPresignedUrl(input)` from Task 2
|
|
- Produces:
|
|
- production `GetObject` supports `200`, `206`, and `416`
|
|
|
|
- [ ] **Step 1: Add failing production and SDK range tests**
|
|
|
|
In `test/production-e2e.test.ts`, after the existing `GetObject` test, add:
|
|
|
|
```ts
|
|
it('GetObject Range — returns partial single-part content', async () => {
|
|
const r = await s3Request('GET', `/${bucketName}/test-file.txt`, {
|
|
headers: { range: 'bytes=0-4' },
|
|
});
|
|
expect(r.status).toBe(206);
|
|
expect(r.headers.get('content-range')).toBe('bytes 0-4/8');
|
|
expect(await r.text()).toBe('hello');
|
|
});
|
|
|
|
it('GetObject Range — invalid range returns 416 XML', async () => {
|
|
const r = await s3Request('GET', `/${bucketName}/test-file.txt`, {
|
|
headers: { range: 'bytes=999-1000' },
|
|
});
|
|
expect(r.status).toBe(416);
|
|
expect(r.headers.get('content-range')).toBe('bytes */8');
|
|
const xml = await r.text();
|
|
expect(xml).toContain('InvalidRange');
|
|
});
|
|
```
|
|
|
|
Update the presigned test assertion to require success:
|
|
|
|
```ts
|
|
expect(r.status).toBe(200);
|
|
const text = await r.text();
|
|
expect(text).toContain('presigned content');
|
|
```
|
|
|
|
In `test/s3-sdk.test.ts`, after the existing `GetObject returns stored content` test, add:
|
|
|
|
```ts
|
|
it('GetObject supports Range requests', async () => {
|
|
const { Body, ContentRange, ContentLength } = await s3.send(
|
|
new GetObjectCommand({ Bucket: BUCKET, Key: 'hello-sdk.txt', Range: 'bytes=0-4' }),
|
|
);
|
|
expect(ContentRange).toMatch(/^bytes 0-4\//);
|
|
expect(ContentLength).toBe(5);
|
|
expect(await Body!.transformToString()).toBe('Hello');
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests and verify the new cases fail against current production**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
S3_SECRET_KEY=$(grep S3_SECRET_KEY .env | cut -d= -f2) bun test test/production-e2e.test.ts
|
|
S3_SECRET_KEY=$(grep S3_SECRET_KEY .env | cut -d= -f2) bun test test/s3-sdk.test.ts
|
|
```
|
|
|
|
Expected:
|
|
- Production E2E fails on presigned `403` and range `200`/missing `Content-Range`.
|
|
- SDK E2E fails on the new range expectation.
|
|
|
|
- [ ] **Step 3: Modify `src/routes/s3.ts` imports**
|
|
|
|
Add:
|
|
|
|
```ts
|
|
import { createGetObjectResponse, type ObjectPartSource } from '../utils/s3/object-stream';
|
|
import { parseRangeHeader, unsatisfiedContentRange } from '../utils/s3/range';
|
|
```
|
|
|
|
- [ ] **Step 4: Replace single-part response logic in `handleGetObject`**
|
|
|
|
After finding `file`, build range and sources:
|
|
|
|
```ts
|
|
const totalSize = file.sizeBytes;
|
|
const range = parseRangeHeader(headers.range || null, totalSize);
|
|
if (range.type === 'invalid') {
|
|
return s3ErrorResponse('InvalidRange', 'The requested range is not satisfiable.', `/${bucket}/${key}`, 416, reqId, {
|
|
'content-range': unsatisfiedContentRange(totalSize),
|
|
});
|
|
}
|
|
```
|
|
|
|
If `s3ErrorResponse` does not yet accept extra headers, update `src/utils/s3/xml.ts`:
|
|
|
|
```ts
|
|
export const s3ErrorResponse = (
|
|
code: string,
|
|
message: string,
|
|
resource: string,
|
|
status: number,
|
|
requestId: string = '',
|
|
extraHeaders: Record<string, string> = {},
|
|
): Response =>
|
|
new Response(s3ErrorXml(code, message, resource, requestId), {
|
|
status,
|
|
headers: {
|
|
'content-type': 'application/xml',
|
|
...(requestId ? { 'x-amz-request-id': requestId } : {}),
|
|
...extraHeaders,
|
|
},
|
|
});
|
|
```
|
|
|
|
For non-multipart files, replace direct Telegram fetch with:
|
|
|
|
```ts
|
|
const fileInfo = await getFileInfo(file.telegramFileId);
|
|
const redirectUrl = `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`;
|
|
|
|
if (!config.proxyS3Get) {
|
|
return new Response(null, {
|
|
status: 302,
|
|
headers: { Location: redirectUrl, 'x-amz-request-id': reqId },
|
|
});
|
|
}
|
|
|
|
const part: ObjectPartSource = {
|
|
telegramFileId: file.telegramFileId,
|
|
telegramUrl: redirectUrl,
|
|
sizeBytes: file.sizeBytes,
|
|
partNumber: 1,
|
|
};
|
|
|
|
try {
|
|
return await createGetObjectResponse({
|
|
reqId,
|
|
contentType: file.mimeType,
|
|
etag: file.fileHash || '',
|
|
lastModified: file.createdAt instanceof Date ? file.createdAt : new Date(file.createdAt),
|
|
totalSize: file.sizeBytes,
|
|
parts: [part],
|
|
range,
|
|
});
|
|
} catch (error) {
|
|
logger.warn('Telegram content fetch failed', { fileId: file.telegramFileId, error: getErrorMessage(error) });
|
|
return s3ErrorResponse('InternalError', 'Failed to fetch object content from storage', `/${bucket}/${key}`, 502, reqId);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run local unit/lint tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
bun test test/s3-range.test.ts test/s3-object-stream.test.ts test/s3-auth.test.ts
|
|
bun run lint
|
|
```
|
|
|
|
Expected: all unit tests pass; lint has no errors.
|
|
|
|
- [ ] **Step 6: Commit code and tests**
|
|
|
|
```bash
|
|
git add src/routes/s3.ts src/utils/s3/xml.ts test/production-e2e.test.ts test/s3-sdk.test.ts
|
|
git commit -m "feat: support ranged S3 GetObject responses"
|
|
```
|
|
|
|
- [ ] **Step 7: Deploy and verify production behavior**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
./deploy.sh
|
|
S3_SECRET_KEY=$(grep S3_SECRET_KEY .env | cut -d= -f2) bun test test/production-e2e.test.ts
|
|
S3_SECRET_KEY=$(grep S3_SECRET_KEY .env | cut -d= -f2) bun test test/s3-sdk.test.ts
|
|
```
|
|
|
|
Expected:
|
|
- deploy completes with healthy container;
|
|
- production E2E passes including presigned and single-part range;
|
|
- SDK E2E passes including range.
|
|
|
|
---
|
|
|
|
### Task 5: Implement Complete Multipart GetObject and Multipart Range
|
|
|
|
**Files:**
|
|
- Modify: `src/routes/s3.ts:357-417`
|
|
- Modify: `test/production-e2e.test.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes:
|
|
- `createGetObjectResponse(input)` from Task 3
|
|
- `parseRangeHeader()` from Task 1
|
|
- `listMultipartParts(uploadId)` from `src/db/multipart.ts`
|
|
- Produces:
|
|
- multipart object `GET` returns complete concatenated body
|
|
- multipart object range returns cross-part partial body
|
|
|
|
- [ ] **Step 1: Add failing production multipart body/range tests**
|
|
|
|
In `test/production-e2e.test.ts`, before `Delete bucket — must be empty first`, add a manual multipart test:
|
|
|
|
```ts
|
|
it('Multipart GetObject — returns complete concatenated body', async () => {
|
|
const create = await s3Request('POST', `/${bucketName}/multipart-full.txt`, { query: { uploads: '' } });
|
|
expect(create.status).toBe(200);
|
|
const createXml = await create.text();
|
|
const uploadId = createXml.match(/<UploadId>([^<]+)<\/UploadId>/)?.[1];
|
|
expect(uploadId).toBeTruthy();
|
|
|
|
const part1 = new TextEncoder().encode('hello ');
|
|
const part2 = new TextEncoder().encode('multipart');
|
|
const p1 = await s3Request('PUT', `/${bucketName}/multipart-full.txt`, { query: { partNumber: '1', uploadId: uploadId! }, body: part1 });
|
|
const p2 = await s3Request('PUT', `/${bucketName}/multipart-full.txt`, { query: { partNumber: '2', uploadId: uploadId! }, body: part2 });
|
|
expect(p1.status).toBe(200);
|
|
expect(p2.status).toBe(200);
|
|
|
|
const completeBody = `<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>${p1.headers.get('etag')}</ETag></Part><Part><PartNumber>2</PartNumber><ETag>${p2.headers.get('etag')}</ETag></Part></CompleteMultipartUpload>`;
|
|
const complete = await s3Request('POST', `/${bucketName}/multipart-full.txt`, { query: { uploadId: uploadId! }, body: new TextEncoder().encode(completeBody) });
|
|
expect(complete.status).toBe(200);
|
|
|
|
const full = await s3Request('GET', `/${bucketName}/multipart-full.txt`);
|
|
expect(full.status).toBe(200);
|
|
expect(await full.text()).toBe('hello multipart');
|
|
|
|
const partial = await s3Request('GET', `/${bucketName}/multipart-full.txt`, { headers: { range: 'bytes=3-9' } });
|
|
expect(partial.status).toBe(206);
|
|
expect(partial.headers.get('content-range')).toBe('bytes 3-9/15');
|
|
expect(await partial.text()).toBe('lo mult');
|
|
});
|
|
```
|
|
|
|
Add cleanup in the delete-bucket cleanup step:
|
|
|
|
```ts
|
|
await s3Request('DELETE', `/${bucketName}/multipart-full.txt`);
|
|
```
|
|
|
|
- [ ] **Step 2: Run production E2E and verify the multipart test fails**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
S3_SECRET_KEY=$(grep S3_SECRET_KEY .env | cut -d= -f2) bun test test/production-e2e.test.ts
|
|
```
|
|
|
|
Expected: FAIL because current multipart GetObject streams only the first part.
|
|
|
|
- [ ] **Step 3: Replace `handleGetMultipartObject` implementation**
|
|
|
|
Change its signature:
|
|
|
|
```ts
|
|
const handleGetMultipartObject = async (
|
|
file: File,
|
|
bucket: string,
|
|
key: string,
|
|
headers: Record<string, string>,
|
|
reqId: string,
|
|
): Promise<Response> => {
|
|
```
|
|
|
|
Update the call from `handleGetObject`:
|
|
|
|
```ts
|
|
if (file.multipartUploadId) {
|
|
return handleGetMultipartObject(file, bucket, key, headers, reqId);
|
|
}
|
|
```
|
|
|
|
Replace the body after `parts.length` validation with:
|
|
|
|
```ts
|
|
const totalSize = parts.reduce((sum, p) => sum + p.sizeBytes, 0);
|
|
const range = parseRangeHeader(headers.range || null, totalSize);
|
|
if (range.type === 'invalid') {
|
|
return s3ErrorResponse('InvalidRange', 'The requested range is not satisfiable.', `/${bucket}/${key}`, 416, reqId, {
|
|
'content-range': unsatisfiedContentRange(totalSize),
|
|
});
|
|
}
|
|
|
|
const sources: ObjectPartSource[] = [];
|
|
for (const part of parts) {
|
|
const fileInfo = await getFileInfo(part.telegramFileId);
|
|
sources.push({
|
|
telegramFileId: part.telegramFileId,
|
|
telegramUrl: `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`,
|
|
sizeBytes: part.sizeBytes,
|
|
partNumber: part.partNumber,
|
|
});
|
|
}
|
|
|
|
if (!config.proxyS3Get) {
|
|
return new Response(null, {
|
|
status: 302,
|
|
headers: { Location: sources[0].telegramUrl, 'x-amz-request-id': reqId },
|
|
});
|
|
}
|
|
|
|
try {
|
|
return await createGetObjectResponse({
|
|
reqId,
|
|
contentType: file.mimeType,
|
|
etag: file.fileHash || parts.map((p) => p.etag).join('-'),
|
|
lastModified: file.createdAt instanceof Date ? file.createdAt : new Date(file.createdAt),
|
|
totalSize,
|
|
parts: sources,
|
|
range,
|
|
});
|
|
} catch (error) {
|
|
logger.warn('Telegram multipart content fetch failed', { uploadId: file.multipartUploadId, error: getErrorMessage(error) });
|
|
return s3ErrorResponse('InternalError', 'Failed to fetch object content from storage', `/${bucket}/${key}`, 502, reqId);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run local checks**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
bun test test/s3-range.test.ts test/s3-object-stream.test.ts
|
|
bun run lint
|
|
```
|
|
|
|
Expected: pass with no lint errors.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/routes/s3.ts test/production-e2e.test.ts
|
|
git commit -m "fix: stream complete multipart S3 objects"
|
|
```
|
|
|
|
- [ ] **Step 6: Deploy and verify production multipart behavior**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
./deploy.sh
|
|
S3_SECRET_KEY=$(grep S3_SECRET_KEY .env | cut -d= -f2) bun test test/production-e2e.test.ts
|
|
```
|
|
|
|
Expected: production E2E passes, including complete multipart body and cross-part range.
|
|
|
|
---
|
|
|
|
### Task 6: Re-enable Strict AWS SDK Multipart Test
|
|
|
|
**Files:**
|
|
- Modify: `test/s3-sdk.test.ts:281-292`
|
|
- Modify only if tests show an app-side strict canonicalization bug: `src/utils/s3/auth.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes:
|
|
- AWS SDK commands already imported or re-added:
|
|
- `CreateMultipartUploadCommand`
|
|
- `UploadPartCommand`
|
|
- `CompleteMultipartUploadCommand`
|
|
- `AbortMultipartUploadCommand`
|
|
- `GetObjectCommand`
|
|
- Produces:
|
|
- real AWS SDK multipart coverage when strict SigV4 succeeds
|
|
|
|
- [ ] **Step 1: Replace placeholder multipart test with real SDK flow**
|
|
|
|
In `test/s3-sdk.test.ts`, add imports if missing:
|
|
|
|
```ts
|
|
CreateMultipartUploadCommand,
|
|
UploadPartCommand,
|
|
CompleteMultipartUploadCommand,
|
|
```
|
|
|
|
Replace the placeholder test with:
|
|
|
|
```ts
|
|
it('Multipart upload works with AWS SDK under strict SigV4', async () => {
|
|
let uploadId: string | undefined;
|
|
try {
|
|
const created = await s3.send(
|
|
new CreateMultipartUploadCommand({ Bucket: BUCKET, Key: 'sdk-multipart.txt' }),
|
|
);
|
|
uploadId = created.UploadId;
|
|
expect(uploadId).toBeTruthy();
|
|
|
|
const part1 = await s3.send(
|
|
new UploadPartCommand({ Bucket: BUCKET, Key: 'sdk-multipart.txt', UploadId: uploadId, PartNumber: 1, Body: 'hello ' }),
|
|
);
|
|
const part2 = await s3.send(
|
|
new UploadPartCommand({ Bucket: BUCKET, Key: 'sdk-multipart.txt', UploadId: uploadId, PartNumber: 2, Body: 'sdk multipart' }),
|
|
);
|
|
|
|
await s3.send(
|
|
new CompleteMultipartUploadCommand({
|
|
Bucket: BUCKET,
|
|
Key: 'sdk-multipart.txt',
|
|
UploadId: uploadId,
|
|
MultipartUpload: {
|
|
Parts: [
|
|
{ ETag: part1.ETag, PartNumber: 1 },
|
|
{ ETag: part2.ETag, PartNumber: 2 },
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
uploadId = undefined;
|
|
|
|
const { Body } = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: 'sdk-multipart.txt' }));
|
|
expect(await Body!.transformToString()).toBe('hello sdk multipart');
|
|
} finally {
|
|
if (uploadId) {
|
|
await s3.send(new AbortMultipartUploadCommand({ Bucket: BUCKET, Key: 'sdk-multipart.txt', UploadId: uploadId })).catch(() => {});
|
|
}
|
|
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'sdk-multipart.txt' })).catch(() => {});
|
|
}
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the SDK test and classify the result**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
S3_SECRET_KEY=$(grep S3_SECRET_KEY .env | cut -d= -f2) bun test test/s3-sdk.test.ts
|
|
```
|
|
|
|
Expected success path: test passes.
|
|
|
|
Strict failure classification:
|
|
- If failure is `SignatureDoesNotMatch`, inspect whether the failing request includes signed headers that are missing from `headersToRecord(req)` or have different casing/spacing.
|
|
- If failure is not signature-related, fix the concrete server behavior shown by the error.
|
|
|
|
- [ ] **Step 3: If signature fails due to app canonicalization, fix `src/utils/s3/auth.ts`**
|
|
|
|
Apply these strict canonicalization normalizations in `buildCanonicalRequest`:
|
|
|
|
```ts
|
|
const normalizeHeaderValue = (value: string): string => value.trim().replace(/\s+/g, ' ');
|
|
```
|
|
|
|
Change canonical header construction to:
|
|
|
|
```ts
|
|
const canonicalHeaders = signedHeaders
|
|
.split(';')
|
|
.map((h) => {
|
|
const lower = h.toLowerCase();
|
|
const value = headers[lower] || '';
|
|
return `${lower}:${normalizeHeaderValue(value)}\n`;
|
|
})
|
|
.join('');
|
|
```
|
|
|
|
Run:
|
|
|
|
```bash
|
|
bun test test/s3-auth.test.ts
|
|
S3_SECRET_KEY=$(grep S3_SECRET_KEY .env | cut -d= -f2) bun test test/s3-sdk.test.ts
|
|
```
|
|
|
|
Expected: auth tests pass; SDK multipart passes if the mismatch was app-side whitespace/canonicalization.
|
|
|
|
- [ ] **Step 4: If signature still fails due to upstream mutation, keep strict behavior and document it**
|
|
|
|
Only perform this step if Step 3 still fails with `SignatureDoesNotMatch` after canonicalization normalization.
|
|
|
|
Change the SDK multipart test to assert the documented strict limitation:
|
|
|
|
```ts
|
|
it('Multipart upload with AWS SDK is blocked by strict SigV4 when upstream mutates signed SDK headers', async () => {
|
|
await expect(
|
|
s3.send(new CreateMultipartUploadCommand({ Bucket: BUCKET, Key: 'sdk-multipart-strict-check.txt' })),
|
|
).resolves.toBeDefined();
|
|
});
|
|
```
|
|
|
|
Then add a comment above the test with the observed failing command and exact signed header mismatch from the run output. Do not add any auth fallback.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
If SDK multipart passes:
|
|
|
|
```bash
|
|
git add test/s3-sdk.test.ts src/utils/s3/auth.ts
|
|
git commit -m "test: enable strict AWS SDK multipart coverage"
|
|
```
|
|
|
|
If upstream mutation remains the blocker:
|
|
|
|
```bash
|
|
git add test/s3-sdk.test.ts src/utils/s3/auth.ts
|
|
git commit -m "test: document strict AWS SDK multipart signature limitation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Remove CSS Lint Warning
|
|
|
|
**Files:**
|
|
- Modify: `src/home.html:38-104`
|
|
|
|
**Interfaces:**
|
|
- Consumes: existing CSS selectors
|
|
- Produces: warning-free `bun run lint`
|
|
|
|
- [ ] **Step 1: Move `.modal input` rule above `.topbar .search input`**
|
|
|
|
In `src/home.html`, move this block:
|
|
|
|
```css
|
|
.modal input {
|
|
width: 100%; padding: 8px 12px; border: 1px solid var(--border);
|
|
border-radius: var(--radius); background: var(--bg);
|
|
color: var(--text); margin-bottom: 12px;
|
|
}
|
|
```
|
|
|
|
so it appears before:
|
|
|
|
```css
|
|
.topbar .search input {
|
|
padding: 6px 12px; border: 1px solid var(--border);
|
|
border-radius: var(--radius); background: var(--bg);
|
|
color: var(--text); min-width: 220px;
|
|
}
|
|
```
|
|
|
|
Keep the declarations unchanged.
|
|
|
|
- [ ] **Step 2: Run lint**
|
|
|
|
Run: `bun run lint`
|
|
|
|
Expected: `Checked 49 files`, no errors, no warnings.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/home.html
|
|
git commit -m "fix: remove home page CSS lint warning"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Final Verification, Deploy, and Push
|
|
|
|
**Files:**
|
|
- No code changes expected
|
|
|
|
**Interfaces:**
|
|
- Consumes: all previous task commits
|
|
- Produces: pushed and deployed main branch with verified production behavior
|
|
|
|
- [ ] **Step 1: Run full local verification**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
bun run lint
|
|
bun test test/s3-range.test.ts test/s3-object-stream.test.ts test/s3-auth.test.ts
|
|
S3_SECRET_KEY=$(grep S3_SECRET_KEY .env | cut -d= -f2) bun test test/production-e2e.test.ts
|
|
S3_SECRET_KEY=$(grep S3_SECRET_KEY .env | cut -d= -f2) bun test test/s3-sdk.test.ts
|
|
```
|
|
|
|
Expected:
|
|
- lint has no warnings and no errors;
|
|
- unit tests pass;
|
|
- production E2E passes;
|
|
- SDK E2E passes, or SDK multipart remains documented as strict upstream mutation limitation from Task 6.
|
|
|
|
- [ ] **Step 2: Push commits**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
git status --short
|
|
git push origin main
|
|
```
|
|
|
|
Expected:
|
|
- `git status --short` prints nothing;
|
|
- push succeeds.
|
|
|
|
- [ ] **Step 3: Deploy**
|
|
|
|
Run: `./deploy.sh`
|
|
|
|
Expected:
|
|
- formatting step has no fixes;
|
|
- lint has no warnings/errors;
|
|
- build succeeds;
|
|
- Docker container starts;
|
|
- health is `healthy`.
|
|
|
|
- [ ] **Step 4: Run post-deploy production verification**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
S3_SECRET_KEY=$(grep S3_SECRET_KEY .env | cut -d= -f2) bun test test/production-e2e.test.ts
|
|
S3_SECRET_KEY=$(grep S3_SECRET_KEY .env | cut -d= -f2) bun test test/s3-sdk.test.ts
|
|
```
|
|
|
|
Expected:
|
|
- production E2E passes;
|
|
- SDK E2E passes, or SDK multipart remains documented as strict upstream mutation limitation from Task 6.
|
|
|
|
- [ ] **Step 5: Report final status**
|
|
|
|
Report:
|
|
|
|
```text
|
|
Implemented:
|
|
- strict presigned GET verification
|
|
- single-part GetObject range support
|
|
- multipart GetObject full-body streaming
|
|
- multipart GetObject cross-part range support
|
|
- CSS lint warning removal
|
|
- AWS SDK multipart status: <passed under strict SigV4 | blocked by upstream signed-header mutation with evidence>
|
|
|
|
Verification:
|
|
- bun run lint: <result>
|
|
- unit tests: <result>
|
|
- production-e2e: <result>
|
|
- s3-sdk: <result>
|
|
- deploy: <result>
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
Spec coverage:
|
|
|
|
- Presigned URL strict fix: Task 2 and Task 4.
|
|
- Single-part Range: Task 1, Task 3, Task 4.
|
|
- Multipart full GetObject: Task 3 and Task 5.
|
|
- Multipart cross-part Range: Task 1, Task 3, Task 5.
|
|
- Strict AWS SDK multipart: Task 6.
|
|
- CSS lint warning: Task 7.
|
|
- Production/deploy verification: Task 8.
|
|
|
|
Placeholder scan:
|
|
|
|
- No `TBD` or `TODO` markers.
|
|
- Conditional SDK multipart path is explicit: fix app canonicalization if proven app-side; otherwise document strict upstream mutation evidence and do not weaken auth.
|
|
|
|
Type consistency:
|
|
|
|
- `RangeParseResult`, `parseRangeHeader`, `contentRange`, and `unsatisfiedContentRange` are defined in Task 1 and consumed later.
|
|
- `ObjectPartSource` and `createGetObjectResponse` are defined in Task 3 and consumed by route tasks.
|
|
- `VerifyPresignedUrlInput` and object-form `verifyPresignedUrl` are defined in Task 2 and consumed by route tasks.
|