mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-04 20:02:26 +00:00
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:
1 parent
4560ca3ca6
commit
a33092fc47
4 files changed
+3289
-27
No files matched your search
@@ -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
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user