mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
fix: asset operations
This commit is contained in:
@@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useRef, useState } from "react"
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { presignAssetUpload, uploadPresigned } from "@/utils/presignedUpload.util";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
// ─── Generic authenticated SSE reader ──────────────────────────────────────
|
// ─── Generic authenticated SSE reader ──────────────────────────────────────
|
||||||
@@ -48,13 +49,9 @@ function streamSSE(url, token, onEvent) {
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload progress (Express -> Garage, real bytes) — see uploadAsset() below.
|
|
||||||
const streamUploadProgress = (uploadId, token, onProgress) =>
|
|
||||||
streamSSE(`${import.meta.env.VITE_API_URL}/admin/assets/upload-progress/${uploadId}`, token, onProgress);
|
|
||||||
|
|
||||||
// Document-conversion stage progress (compiling/validating/generating) — see
|
// Document-conversion stage progress (compiling/validating/generating) — see
|
||||||
// convertAssetToMarkdown() below. Same broadcaster/channel shape on the
|
// convertAssetToMarkdown() below. Backend broadcaster is
|
||||||
// backend (services/uploadProgress.service.js), just a different job id.
|
// services/uploadProgress.service.js, keyed by a client-generated job id.
|
||||||
const streamConvertProgress = (jobId, token, onProgress) =>
|
const streamConvertProgress = (jobId, token, onProgress) =>
|
||||||
streamSSE(`${import.meta.env.VITE_API_URL}/admin/assets/convert-progress/${jobId}`, token, onProgress);
|
streamSSE(`${import.meta.env.VITE_API_URL}/admin/assets/convert-progress/${jobId}`, token, onProgress);
|
||||||
|
|
||||||
@@ -242,52 +239,51 @@ export function AssetsProvider({ children }) {
|
|||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── POST /api/admin/assets ───────────────────────────────────────────────
|
// ─── POST /api/admin/assets/presign + direct PUT + POST /api/admin/assets ──
|
||||||
//
|
//
|
||||||
// onProgress?: ({ phase: 'uploading'|'storing'|'done'|'error', pct }) => void
|
// onProgress?: ({ phase: 'uploading'|'processing'|'done', pct }) => void
|
||||||
// Two honest, sequential phases — not one blended/estimated number:
|
// "uploading" — browser -> storage, real bytes sent directly (this
|
||||||
// "uploading" — browser -> this backend, real bytes sent (axios onUploadProgress).
|
// backend is never in that data path at all anymore).
|
||||||
// "storing" — this backend -> Garage, the actual S3 PUT, streamed live over
|
// "processing" — brief server-side step once the upload lands: reads the
|
||||||
// SSE (see streamUploadProgress above / uploadProgress.service.js
|
// object back (HeadObjectCommand), runs ffprobe for
|
||||||
// on the backend). Real numbers from the real transfer, both legs.
|
// video/audio, inserts the DB row.
|
||||||
const uploadAsset = useCallback(
|
const uploadAsset = useCallback(
|
||||||
({ file, thumbnail, onProgress, ...rest }) =>
|
({ file, thumbnail, onProgress, ...rest }) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const uploadId = nanoid();
|
const [mainPresign, thumbPresign] = await Promise.all([
|
||||||
const form = new FormData();
|
presignAssetUpload(file),
|
||||||
form.append("file", file);
|
thumbnail ? presignAssetUpload(thumbnail) : Promise.resolve(null),
|
||||||
if (thumbnail) form.append("thumbnail", thumbnail);
|
]);
|
||||||
form.append("uploadId", uploadId);
|
const storage_key = mainPresign.key;
|
||||||
Object.entries(rest).forEach(([k, v]) => {
|
|
||||||
if (v !== undefined && v !== null) form.append(k, v);
|
await Promise.all([
|
||||||
|
uploadPresigned(file, mainPresign, (pct) => onProgress?.({ phase: "uploading", pct })),
|
||||||
|
thumbPresign ? uploadPresigned(thumbnail, thumbPresign) : Promise.resolve(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
onProgress?.({ phase: "processing", pct: 100 });
|
||||||
|
|
||||||
|
const res = await api.post("/admin/assets", {
|
||||||
|
storage_key,
|
||||||
|
thumbnail_storage_key: thumbPresign?.key,
|
||||||
|
original_name: file.name,
|
||||||
|
// Fallback only — the backend prefers storage's own
|
||||||
|
// Content-Type, this just covers the rare case a browser
|
||||||
|
// sent the PUT with no Content-Type at all (empty File.type).
|
||||||
|
mimetype: file.type || undefined,
|
||||||
|
...rest,
|
||||||
});
|
});
|
||||||
|
|
||||||
const stopStream = onProgress
|
const asset = res.data?.data?.data ?? null;
|
||||||
? streamUploadProgress(uploadId, accessTokenRef.current, onProgress)
|
if (asset) {
|
||||||
: null;
|
setAssets((prev) => [asset, ...prev]);
|
||||||
|
invalidateListCache();
|
||||||
try {
|
toast("Asset uploaded successfully.");
|
||||||
const res = await api.post("/admin/assets", form, {
|
|
||||||
onUploadProgress: onProgress
|
|
||||||
? (evt) => {
|
|
||||||
const pct = evt.total ? Math.round((evt.loaded / evt.total) * 100) : 0;
|
|
||||||
onProgress({ phase: "uploading", pct });
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
const asset = res.data?.data?.data ?? null;
|
|
||||||
if (asset) {
|
|
||||||
setAssets((prev) => [asset, ...prev]);
|
|
||||||
invalidateListCache();
|
|
||||||
toast("Asset uploaded successfully.");
|
|
||||||
}
|
|
||||||
onProgress?.({ phase: "done", pct: 100 });
|
|
||||||
return res.data;
|
|
||||||
} finally {
|
|
||||||
stopStream?.();
|
|
||||||
}
|
}
|
||||||
|
onProgress?.({ phase: "done", pct: 100 });
|
||||||
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request, accessTokenRef]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── POST /api/admin/assets/:assetId/convert-to-markdown ─────────────────
|
// ─── POST /api/admin/assets/:assetId/convert-to-markdown ─────────────────
|
||||||
|
|||||||
@@ -6,25 +6,23 @@
|
|||||||
// UploadProgressToast reads the same state from anywhere in /admin.
|
// UploadProgressToast reads the same state from anywhere in /admin.
|
||||||
//
|
//
|
||||||
// Every job — whether it's part of an initial addBatch() or a single retry —
|
// Every job — whether it's part of an initial addBatch() or a single retry —
|
||||||
// goes out as its OWN POST /admin/assets/batch request (that endpoint has
|
// uploads directly to storage via its own presigned PUT (see
|
||||||
// always accepted 1-20 files; sending 1 is a fully supported path). This
|
// presignedUpload.util.js), then finalizes with its own POST /admin/assets
|
||||||
// matters beyond just per-file progress: the backend processes a batch
|
// call — the same per-file isolation as before (a dropped connection can
|
||||||
// request's files sequentially inside one request/response cycle and never
|
// only ever affect the one job riding it), now with the added benefit that
|
||||||
// aborts on client disconnect, so a connection drop anywhere during a
|
// the file's bytes never pass through this backend's memory at all, no
|
||||||
// combined multi-file request used to fail EVERY job in that request, even
|
// matter how large the file is. uploadOne() below is shared by both the
|
||||||
// ones the server had already finished (S3 object + DB row both created) —
|
// initial run and retries; runBatch() just fans it out over a small worker
|
||||||
// clicking Retry on those then silently created a duplicate. One request per
|
// pool so a big batch doesn't fire 20 uploads at once (MAX_CONCURRENT
|
||||||
// file means a dropped connection can only affect the one job riding it.
|
// mirrors the Garage-edge Caddy's own concurrent-upload cap, so the
|
||||||
// uploadOne() below is shared by both the initial run and retries; runBatch()
|
// frontend doesn't just relocate the pile-up into tripping that limit
|
||||||
// just fans it out over a small worker pool so a big batch doesn't open 20
|
// instead).
|
||||||
// connections at once (MAX_CONCURRENT mirrors garage-anon-proxy's own
|
|
||||||
// MAX_CONCURRENT_UPLOADS=3 ceiling on the S3 side, so the frontend doesn't
|
|
||||||
// just relocate the pile-up into tripping that limit instead).
|
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
import { validateAssetFile } from "@/utils/assetUpload.util";
|
import { validateAssetFile } from "@/utils/assetUpload.util";
|
||||||
|
import { presignAssetUpload, uploadPresigned } from "@/utils/presignedUpload.util";
|
||||||
|
|
||||||
const MAX_CONCURRENT = 3;
|
const MAX_CONCURRENT = 3;
|
||||||
|
|
||||||
@@ -47,31 +45,34 @@ export function UploadQueueProvider({ children }) {
|
|||||||
setJobs((prev) => prev.map((j) => (idSet.has(j.id) ? { ...j, ...(typeof patch === "function" ? patch(j) : patch) } : j)));
|
setJobs((prev) => prev.map((j) => (idSet.has(j.id) ? { ...j, ...(typeof patch === "function" ? patch(j) : patch) } : j)));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ─── Upload exactly one job through its own request ────────────────────
|
// ─── Upload exactly one job: presign -> direct PUT -> finalize ─────────
|
||||||
// Shared by the initial batch run and single-job retries — a dropped
|
// Shared by the initial batch run and single-job retries — a dropped
|
||||||
// connection here can only ever fail the one job riding this request,
|
// connection here can only ever fail the one job riding it, never its
|
||||||
// never its batch-mates.
|
// batch-mates. Same isolation guarantee as before, plus the file's bytes
|
||||||
|
// now go straight to storage instead of buffering through the backend.
|
||||||
const uploadOne = useCallback(async (job) => {
|
const uploadOne = useCallback(async (job) => {
|
||||||
const form = new FormData();
|
|
||||||
form.append("files", job.file);
|
|
||||||
Object.entries(job.meta).forEach(([k, v]) => {
|
|
||||||
if (v !== undefined && v !== null) form.append(k, v);
|
|
||||||
});
|
|
||||||
// Bulk display_name derivation only applies when the caller didn't
|
|
||||||
// already provide one — each job keeps whatever name it resolves to.
|
|
||||||
if (!job.meta.display_name) form.append("display_name", baseNameOf(job.name));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post("/admin/assets/batch", form, {
|
const presigned = await presignAssetUpload(job.file);
|
||||||
onUploadProgress: (evt) => {
|
const storage_key = presigned.key;
|
||||||
const pct = evt.total ? Math.round((evt.loaded / evt.total) * 100) : 0;
|
|
||||||
patchJobs([job.id], { progress: pct });
|
await uploadPresigned(job.file, presigned, (pct) => patchJobs([job.id], { progress: pct }));
|
||||||
},
|
|
||||||
|
// Bulk display_name derivation only applies when the caller didn't
|
||||||
|
// already provide one — each job keeps whatever name it resolves to.
|
||||||
|
const display_name = job.meta.display_name || baseNameOf(job.name);
|
||||||
|
|
||||||
|
const { data } = await api.post("/admin/assets", {
|
||||||
|
...job.meta,
|
||||||
|
storage_key,
|
||||||
|
original_name: job.name,
|
||||||
|
display_name,
|
||||||
|
// Fallback only — the backend prefers storage's own
|
||||||
|
// Content-Type, this just covers the rare case a browser
|
||||||
|
// sent the PUT with no Content-Type at all (empty File.type).
|
||||||
|
mimetype: job.mime || undefined,
|
||||||
});
|
});
|
||||||
const result = data?.data?.results?.[0];
|
const asset = data?.data?.data;
|
||||||
patchJobs([job.id], result?.success
|
patchJobs([job.id], { status: "uploaded", progress: 100, asset, error: null });
|
||||||
? { status: "uploaded", progress: 100, asset: result.data, error: null }
|
|
||||||
: { status: "failed", progress: 100, error: result?.message || "Upload failed." });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// No sonner toast() here — the floating widget (both corners
|
// No sonner toast() here — the floating widget (both corners
|
||||||
// would collide, see UploadProgressToast) already surfaces this
|
// would collide, see UploadProgressToast) already surfaces this
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ArrowLeft, ArrowRight, UploadCloud, X, FileVideo, FileText, Image, File
|
|||||||
|
|
||||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { MAX_ASSET_FILE_SIZE, MAX_ASSET_FILE_SIZE_LABEL } from "@/utils/assetUpload.util";
|
||||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -34,11 +35,6 @@ function resolveFileType(mimeType = "") {
|
|||||||
return "document";
|
return "document";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matches asset_upload.middleware.js on the backend — checked here too so an
|
|
||||||
// oversized file is rejected instantly instead of only after a full upload
|
|
||||||
// attempt round-trips to the server.
|
|
||||||
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
|
|
||||||
|
|
||||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
@@ -128,7 +124,7 @@ export default function AddAsset() {
|
|||||||
const fileRef = useRef(null);
|
const fileRef = useRef(null);
|
||||||
const thumbnailRef = useRef(null);
|
const thumbnailRef = useRef(null);
|
||||||
const [thumbKey, setThumbKey] = useState(0);
|
const [thumbKey, setThumbKey] = useState(0);
|
||||||
const [progress, setProgress] = useState(null); // { phase: 'uploading'|'storing'|'done'|'error', pct } | null
|
const [progress, setProgress] = useState(null); // { phase: 'uploading'|'processing'|'done'|'error', pct } | null
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -161,8 +157,8 @@ export default function AddAsset() {
|
|||||||
const fileType = file ? resolveFileType(file.type) : null;
|
const fileType = file ? resolveFileType(file.type) : null;
|
||||||
|
|
||||||
const setFile = (f) => {
|
const setFile = (f) => {
|
||||||
if (f.size > MAX_FILE_SIZE) {
|
if (f.size > MAX_ASSET_FILE_SIZE) {
|
||||||
setError("_file", { message: `File exceeds the ${MAX_FILE_SIZE / (1024 * 1024)} MB size limit.` });
|
setError("_file", { message: `File exceeds the ${MAX_ASSET_FILE_SIZE_LABEL} size limit.` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fileRef.current = f;
|
fileRef.current = f;
|
||||||
@@ -205,10 +201,10 @@ export default function AddAsset() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const progressLabel = {
|
const progressLabel = {
|
||||||
uploading: "Uploading to server…",
|
uploading: "Uploading…",
|
||||||
storing: "Storing to server...",
|
processing: "Processing…",
|
||||||
done: "Done.",
|
done: "Done.",
|
||||||
error: "Upload failed.",
|
error: "Upload failed.",
|
||||||
}[progress?.phase];
|
}[progress?.phase];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -28,10 +28,15 @@ const ALLOWED_DOCUMENT_EXTENSIONS = new Set([
|
|||||||
"zip", "json", "rtf", "txt", "csv", "md",
|
"zip", "json", "rtf", "txt", "csv", "md",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Matches asset_upload.middleware.js on the backend — rejecting an oversized
|
// Uploads go straight to storage via a presigned URL (see
|
||||||
// file here means the queue shows a clear "invalid" reason instantly instead
|
// presignedUpload.util.js) — this backend never buffers the file. Above 5GB
|
||||||
// of a job that uploads for a while and then fails with a generic error.
|
// (S3-compatible storage's own single-PUT ceiling) uploads switch to real
|
||||||
export const MAX_ASSET_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
|
// multipart automatically; 15GB is the app-level ceiling chosen for future
|
||||||
|
// assets, well within multipart's own much larger real limit. Rejecting an
|
||||||
|
// oversized file here still means the queue shows a clear "invalid" reason
|
||||||
|
// instantly instead of a job that uploads for a while and then fails.
|
||||||
|
export const MAX_ASSET_FILE_SIZE = 15 * 1024 * 1024 * 1024; // 15 GB
|
||||||
|
export const MAX_ASSET_FILE_SIZE_LABEL = "15 GB";
|
||||||
|
|
||||||
export function fileExtension(filename = "") {
|
export function fileExtension(filename = "") {
|
||||||
const dot = filename.lastIndexOf(".");
|
const dot = filename.lastIndexOf(".");
|
||||||
@@ -43,7 +48,7 @@ export function validateAssetFile(file) {
|
|||||||
if (file.size > MAX_ASSET_FILE_SIZE) {
|
if (file.size > MAX_ASSET_FILE_SIZE) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
reason: `File exceeds the ${MAX_ASSET_FILE_SIZE / (1024 * 1024)} MB size limit.`,
|
reason: `File exceeds the ${MAX_ASSET_FILE_SIZE_LABEL} size limit.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// utils/presignedUpload.util.js
|
||||||
|
//
|
||||||
|
// Shared by AdminAssetsContext.jsx (single-file Add Asset) and
|
||||||
|
// UploadQueueContext.jsx (Add Assets Bulk) — the browser uploads file bytes
|
||||||
|
// directly to storage via a short-lived presigned URL, so this backend
|
||||||
|
// never buffers them regardless of file size. presignAssetUpload() mints
|
||||||
|
// the URL(s); putDirect()/putMultipart() send the bytes with real progress
|
||||||
|
// events, and deliberately bypass the shared `api` axios instance since
|
||||||
|
// they target a different origin (the storage host) and must not pick up
|
||||||
|
// the app's Authorization-header interceptor or any other header that
|
||||||
|
// wasn't part of what the URL was signed for.
|
||||||
|
//
|
||||||
|
// Files at or below storage's single-PUT ceiling (5GB) get one presigned
|
||||||
|
// PUT (putDirect). Anything larger uses real multipart upload (putMultipart)
|
||||||
|
// — the backend splits it into 50MB parts, presigns each one, and the
|
||||||
|
// browser uploads them through a small concurrent worker pool, retrying an
|
||||||
|
// individual failed part a couple of times before giving up entirely.
|
||||||
|
|
||||||
|
import api from "@/utils/api.util";
|
||||||
|
|
||||||
|
const MAX_PART_RETRIES = 2;
|
||||||
|
const MAX_CONCURRENT_PARTS = 3;
|
||||||
|
|
||||||
|
export function resolveFileType(mimetype = "") {
|
||||||
|
if (mimetype.startsWith("image/")) return "image";
|
||||||
|
if (mimetype.startsWith("video/")) return "video";
|
||||||
|
if (mimetype.startsWith("audio/")) return "audio";
|
||||||
|
return "document";
|
||||||
|
}
|
||||||
|
|
||||||
|
// -> { key, uploadUrl } (size <= 5GB)
|
||||||
|
// -> { key, multipart: true, uploadId, partSize, parts } (size > 5GB)
|
||||||
|
export async function presignAssetUpload(file) {
|
||||||
|
const { data } = await api.post("/admin/assets/presign", {
|
||||||
|
filename: file.name,
|
||||||
|
mimetype: file.type,
|
||||||
|
file_type: resolveFileType(file.type),
|
||||||
|
size: file.size,
|
||||||
|
});
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function putDirect(uploadUrl, file, onProgress) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("PUT", uploadUrl);
|
||||||
|
if (onProgress) {
|
||||||
|
xhr.upload.onprogress = (evt) => {
|
||||||
|
onProgress(evt.lengthComputable ? Math.round((evt.loaded / evt.total) * 100) : 0);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||||
|
else reject(new Error(`Upload failed (${xhr.status}).`));
|
||||||
|
};
|
||||||
|
xhr.onerror = () => reject(new Error("Network error during upload."));
|
||||||
|
xhr.send(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single part PUT — resolves with the ETag S3 returns for that part (needed
|
||||||
|
// to build the CompleteMultipartUpload part list later). Requires ETag to be
|
||||||
|
// listed in the storage edge's Access-Control-Expose-Headers, since it's
|
||||||
|
// otherwise invisible to JS on a cross-origin response.
|
||||||
|
function putPart(uploadUrl, blob, onLoaded) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("PUT", uploadUrl);
|
||||||
|
xhr.upload.onprogress = (evt) => {
|
||||||
|
if (evt.lengthComputable) onLoaded(evt.loaded);
|
||||||
|
};
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
const etag = xhr.getResponseHeader("ETag");
|
||||||
|
if (!etag) { reject(new Error("Part upload succeeded but no ETag was returned.")); return; }
|
||||||
|
onLoaded(blob.size);
|
||||||
|
resolve(etag);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Part upload failed (${xhr.status}).`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror = () => reject(new Error("Network error during part upload."));
|
||||||
|
xhr.send(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putPartWithRetry(uploadUrl, blob, onLoaded) {
|
||||||
|
let lastErr;
|
||||||
|
for (let attempt = 0; attempt <= MAX_PART_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
return await putPart(uploadUrl, blob, onLoaded);
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
onLoaded(0); // reset this part's contribution before retrying
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// multipartInfo: { uploadId, partSize, parts: [{ partNumber, uploadUrl }] }
|
||||||
|
// -> [{ partNumber, etag }, ...] (unordered — completeMultipartUpload sorts)
|
||||||
|
export async function putMultipart(file, multipartInfo, onProgress) {
|
||||||
|
const { partSize, parts } = multipartInfo;
|
||||||
|
const loadedByPart = new Array(parts.length).fill(0);
|
||||||
|
|
||||||
|
const reportProgress = () => {
|
||||||
|
if (!onProgress) return;
|
||||||
|
const loaded = loadedByPart.reduce((sum, n) => sum + n, 0);
|
||||||
|
onProgress(Math.min(100, Math.round((loaded / file.size) * 100)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = new Array(parts.length);
|
||||||
|
let next = 0;
|
||||||
|
const worker = async () => {
|
||||||
|
while (next < parts.length) {
|
||||||
|
const i = next++;
|
||||||
|
const { partNumber, uploadUrl } = parts[i];
|
||||||
|
const start = (partNumber - 1) * partSize;
|
||||||
|
const end = Math.min(start + partSize, file.size);
|
||||||
|
const blob = file.slice(start, end);
|
||||||
|
|
||||||
|
const etag = await putPartWithRetry(uploadUrl, blob, (loaded) => {
|
||||||
|
loadedByPart[i] = loaded;
|
||||||
|
reportProgress();
|
||||||
|
});
|
||||||
|
results[i] = { partNumber, etag };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(MAX_CONCURRENT_PARTS, parts.length) }, worker)
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uploads one file to whatever presignAssetUpload() returned — single PUT or
|
||||||
|
// full multipart, whichever the file's size required — and leaves storage
|
||||||
|
// clean on failure (aborts a partially-uploaded multipart session rather
|
||||||
|
// than leaving it to linger). Shared by AdminAssetsContext.jsx and
|
||||||
|
// UploadQueueContext.jsx so neither has to duplicate this branch.
|
||||||
|
export async function uploadPresigned(file, presigned, onProgress) {
|
||||||
|
if (!presigned.multipart) {
|
||||||
|
await putDirect(presigned.uploadUrl, file, onProgress);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parts = await putMultipart(file, presigned, onProgress);
|
||||||
|
await completeMultipartUpload(presigned.key, presigned.uploadId, parts);
|
||||||
|
} catch (err) {
|
||||||
|
await abortMultipartUpload(presigned.key, presigned.uploadId);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeMultipartUpload(storage_key, uploadId, parts) {
|
||||||
|
await api.post("/admin/assets/complete-multipart", { storage_key, uploadId, parts });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function abortMultipartUpload(storage_key, uploadId) {
|
||||||
|
// Best-effort — a failed cleanup call shouldn't mask the real upload
|
||||||
|
// error that triggered the abort in the first place.
|
||||||
|
try {
|
||||||
|
await api.post("/admin/assets/abort-multipart", { storage_key, uploadId });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user