feat(upload): support presigned multipart manifest for S3 uploads

- Updated `js/s3-quick.js` to support new manifest structure: `{ bucket, key, uploadId, parts: [{partNumber, url}] }`.
- Added logic to sort parts, upload chunks, collect ETags, and call `completeUrl` if present.
- Updated `js/ui/components/UploadModal.js` to handle manifest flow, validate new structure, and alert user if completion is manual (missing `completeUrl`).
- Updated `components/upload-modal.html` with new placeholder example and documentation note.
- Verified logic with reproduction script and UI with Playwright.
This commit is contained in:
google-labs-jules[bot] committed 2026-01-25 02:44:49 +00:00
1 parent 4560ca3ca6
commit a33092fc47
4 files changed
+3289 -27

No files matched your search

+4 -1
View File
@@ -319,11 +319,14 @@
<div id="quickS3ManifestContainer" class="hidden space-y-2">
<label for="quickS3ManifestJson" class="text-xs font-medium text-text">Manifest JSON</label>
<p class="text-4xs text-muted leading-tight">
The manifest must be produced externally. This app does not create or sign presigned URLs.
</p>
<textarea
id="quickS3ManifestJson"
rows="6"
class="textarea text-xs font-mono"
placeholder='{"uploadId": "...", "partUrls": ["..."]}'
placeholder='{"bucket": "...", "key": "...", "uploadId": "...", "parts": [{"partNumber": 1, "url": "..."}]}'
></textarea>
</div>
+47 -25
View File
@@ -91,45 +91,67 @@ export async function uploadWithManifest({
manifest,
onProgress
}) {
// manifest: { uploadId, partUrls: string[], completeUrl?: string }
if (!manifest || !Array.isArray(manifest.partUrls) || manifest.partUrls.length === 0) {
throw new Error("Invalid manifest: partUrls required.");
// manifest: {
// bucket: string,
// key: string,
// uploadId: string,
// parts: { partNumber: number, url: string }[],
// completeUrl?: string,
// partSize?: number
// }
if (!manifest || !Array.isArray(manifest.parts) || manifest.parts.length === 0) {
throw new Error("Invalid manifest: 'parts' array required.");
}
const partUrls = manifest.partUrls;
const partCount = partUrls.length;
// Ensure parts are sorted
const parts = [...manifest.parts].sort((a, b) => a.partNumber - b.partNumber);
const fileSize = file.size;
// Calculate part size. We assume the manifest was generated for this file size.
// We divide equally, last part takes remainder.
const partSize = Math.ceil(fileSize / partCount);
const partCount = parts.length;
// Determine part size
// If not provided, assume standard S3 splitting (equal parts except last)
const partSize = manifest.partSize || Math.ceil(fileSize / partCount);
const etags = [];
let uploadedBytes = 0;
for (let i = 0; i < partCount; i++) {
const start = i * partSize;
if (start >= fileSize) break; // Should not happen if calc is right
const part = parts[i];
const partNum = part.partNumber;
const url = part.url;
// Calculate byte range
const index = i;
const start = index * partSize;
if (start >= fileSize) break;
const end = Math.min(start + partSize, fileSize);
const chunk = file.slice(start, end);
const url = partUrls[i];
// Simple PUT to presigned URL
const response = await fetch(url, {
method: 'PUT',
body: chunk
});
try {
const response = await fetch(url, {
method: 'PUT',
body: chunk
});
if (!response.ok) {
throw new Error(`Failed to upload part ${i+1}: ${response.statusText}`);
}
if (!response.ok) {
throw new Error(`Failed to upload part ${partNum}: ${response.status} ${response.statusText}`);
}
const etag = response.headers.get('ETag');
// ETag is often needed for completion.
etags.push({ PartNumber: i + 1, ETag: etag });
uploadedBytes += chunk.size;
const etag = response.headers.get('ETag');
if (!etag) {
userLogger.warn(`Part ${partNum} uploaded but no ETag header received.`);
}
if (onProgress) {
onProgress(uploadedBytes / fileSize);
etags.push({ PartNumber: partNum, ETag: etag });
uploadedBytes += chunk.size;
if (onProgress) {
onProgress(uploadedBytes / fileSize);
}
} catch (err) {
throw new Error(`Network error uploading part ${partNum}: ${err.message}`);
}
}
+10 -1
View File
@@ -1410,12 +1410,21 @@ export class UploadModal {
this.updateProgress(0);
try {
await uploadWithManifest({
const etags = await uploadWithManifest({
file,
manifest,
onProgress: (fraction) => this.updateProgress(fraction),
});
if (!manifest.completeUrl) {
this.updateUploadStatus("Parts uploaded. Action required.", "warning");
const msg = "Upload parts finished. Please complete the upload in your provider console.";
userLogger.info("ETags for manual completion:", JSON.stringify({ Parts: etags }, null, 2));
alert(`${msg}\n\nETags have been logged to the console.`);
this.showSuccess(msg);
return;
}
this.updateUploadStatus("Processing metadata...", "info");
// We can't upload thumbnail/torrent without keys or manifest for them.
+3228
View File
File diff suppressed because it is too large. Load diff