284 lines
7.5 KiB
TypeScript
284 lines
7.5 KiB
TypeScript
import api from "./api";
|
|
import {
|
|
resizeImage,
|
|
getVariantKey,
|
|
getSizeSuffix,
|
|
} from "../utils/imageResizer";
|
|
|
|
/**
|
|
* Get the public URL for an image (S3 only)
|
|
*/
|
|
export const getPublicImageUrl = (
|
|
imagePath: string | null | undefined
|
|
): string => {
|
|
if (!imagePath) return "";
|
|
|
|
// Already a full S3 URL
|
|
if (imagePath.startsWith("https://") && imagePath.includes("s3.")) {
|
|
return imagePath;
|
|
}
|
|
|
|
// S3 key (e.g., "profiles/uuid.jpg", "items/uuid.jpg", "forum/uuid.jpg")
|
|
const s3Bucket = process.env.REACT_APP_S3_BUCKET || "";
|
|
const s3Region = process.env.REACT_APP_AWS_REGION || "us-east-1";
|
|
return `https://${s3Bucket}.s3.${s3Region}.amazonaws.com/${imagePath}`;
|
|
};
|
|
|
|
export interface PresignedUrlResponse {
|
|
uploadUrl: string;
|
|
key: string;
|
|
publicUrl: string;
|
|
expiresAt: string;
|
|
}
|
|
|
|
export type UploadType =
|
|
| "profile"
|
|
| "item"
|
|
| "message"
|
|
| "forum"
|
|
| "condition-check";
|
|
|
|
interface UploadOptions {
|
|
onProgress?: (percent: number) => void;
|
|
maxRetries?: number;
|
|
}
|
|
|
|
/**
|
|
* Get a presigned URL for uploading a single file
|
|
*/
|
|
export async function getPresignedUrl(
|
|
uploadType: UploadType,
|
|
file: File
|
|
): Promise<PresignedUrlResponse> {
|
|
const response = await api.post("/upload/presign", {
|
|
uploadType,
|
|
contentType: file.type,
|
|
fileName: file.name,
|
|
fileSize: file.size,
|
|
});
|
|
return response.data;
|
|
}
|
|
|
|
/**
|
|
* Get presigned URLs for uploading multiple files
|
|
*/
|
|
export async function getPresignedUrls(
|
|
uploadType: UploadType,
|
|
files: File[]
|
|
): Promise<PresignedUrlResponse[]> {
|
|
const response = await api.post("/upload/presign-batch", {
|
|
uploadType,
|
|
files: files.map((f) => ({
|
|
contentType: f.type,
|
|
fileName: f.name,
|
|
fileSize: f.size,
|
|
})),
|
|
});
|
|
return response.data.uploads;
|
|
}
|
|
|
|
/**
|
|
* Upload a file directly to S3 using a presigned URL
|
|
*/
|
|
export async function uploadToS3(
|
|
file: File,
|
|
uploadUrl: string,
|
|
options: UploadOptions = {}
|
|
): Promise<void> {
|
|
const { onProgress, maxRetries = 3 } = options;
|
|
|
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open("PUT", uploadUrl, true);
|
|
xhr.setRequestHeader("Content-Type", file.type);
|
|
|
|
if (onProgress) {
|
|
xhr.upload.onprogress = (e) => {
|
|
if (e.lengthComputable) {
|
|
onProgress(Math.round((e.loaded / e.total) * 100));
|
|
}
|
|
};
|
|
}
|
|
|
|
xhr.onload = () => {
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
resolve();
|
|
} else {
|
|
reject(new Error(`HTTP ${xhr.status}`));
|
|
}
|
|
};
|
|
|
|
xhr.onerror = () => reject(new Error("Network error"));
|
|
xhr.send(file);
|
|
});
|
|
return;
|
|
} catch (error) {
|
|
if (attempt === maxRetries - 1) throw error;
|
|
// Exponential backoff
|
|
await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Confirm that files have been uploaded to S3
|
|
*/
|
|
export async function confirmUploads(
|
|
keys: string[]
|
|
): Promise<{ confirmed: string[]; total: number }> {
|
|
const response = await api.post("/upload/confirm", { keys });
|
|
return response.data;
|
|
}
|
|
|
|
/**
|
|
* Upload a single file to S3 (complete flow)
|
|
*/
|
|
export async function uploadFile(
|
|
uploadType: UploadType,
|
|
file: File,
|
|
options: UploadOptions = {}
|
|
): Promise<{ key: string; publicUrl: string }> {
|
|
// Get presigned URL
|
|
const presigned = await getPresignedUrl(uploadType, file);
|
|
|
|
// Upload to S3
|
|
await uploadToS3(file, presigned.uploadUrl, options);
|
|
|
|
// Confirm upload
|
|
const { confirmed } = await confirmUploads([presigned.key]);
|
|
|
|
if (confirmed.length === 0) {
|
|
throw new Error("Upload verification failed");
|
|
}
|
|
|
|
return { key: presigned.key, publicUrl: presigned.publicUrl };
|
|
}
|
|
|
|
/**
|
|
* Get a signed URL for accessing private content (messages, condition-checks)
|
|
*/
|
|
export async function getSignedUrl(key: string): Promise<string> {
|
|
const response = await api.get(
|
|
`/upload/signed-url/${encodeURIComponent(key)}`
|
|
);
|
|
return response.data.url;
|
|
}
|
|
|
|
/**
|
|
* Get a signed URL for a specific image size variant (private content)
|
|
* Backend will validate ownership using the base key
|
|
*/
|
|
export async function getSignedImageUrl(
|
|
baseKey: string,
|
|
size: "thumbnail" | "medium" | "original" = "original"
|
|
): Promise<string> {
|
|
const suffix = getSizeSuffix(size);
|
|
const variantKey = getVariantKey(baseKey, suffix);
|
|
return getSignedUrl(variantKey);
|
|
}
|
|
|
|
/**
|
|
* Get URL for a specific image size variant
|
|
* Falls back to original if variant doesn't exist (backward compatibility)
|
|
*/
|
|
export function getImageUrl(
|
|
baseKey: string | null | undefined,
|
|
size: "thumbnail" | "medium" | "original" = "original"
|
|
): string {
|
|
if (!baseKey) return "";
|
|
|
|
const suffix = getSizeSuffix(size);
|
|
const variantKey = getVariantKey(baseKey, suffix);
|
|
|
|
return getPublicImageUrl(variantKey);
|
|
}
|
|
|
|
export interface UploadWithResizeOptions extends UploadOptions {
|
|
skipResize?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Upload a single image with all size variants (thumbnail, medium, original)
|
|
* Returns the base key (original, without suffix) for database storage
|
|
*/
|
|
export async function uploadImageWithVariants(
|
|
uploadType: UploadType,
|
|
file: File,
|
|
options: UploadWithResizeOptions = {}
|
|
): Promise<{ baseKey: string; publicUrl: string; variants: string[] }> {
|
|
const { onProgress, skipResize } = options;
|
|
|
|
// If skipping resize, use regular upload
|
|
if (skipResize) {
|
|
const result = await uploadFile(uploadType, file, { onProgress });
|
|
return { baseKey: result.key, publicUrl: result.publicUrl, variants: [result.key] };
|
|
}
|
|
|
|
// Generate resized variants
|
|
const resizedImages = await resizeImage(file);
|
|
|
|
if (resizedImages.length === 0) {
|
|
throw new Error("Failed to resize image");
|
|
}
|
|
|
|
// Get presigned URLs for all variants
|
|
const files = resizedImages.map((r) => r.file);
|
|
const presignedUrls = await getPresignedUrls(uploadType, files);
|
|
|
|
// Upload all variants in parallel with combined progress
|
|
const totalBytes = files.reduce((sum, f) => sum + f.size, 0);
|
|
let uploadedBytes = 0;
|
|
|
|
await Promise.all(
|
|
files.map((variantFile, i) =>
|
|
uploadToS3(variantFile, presignedUrls[i].uploadUrl, {
|
|
onProgress: (percent) => {
|
|
if (onProgress) {
|
|
const fileContribution = (variantFile.size / totalBytes) * percent;
|
|
// Approximate combined progress
|
|
onProgress(Math.min(99, Math.round(uploadedBytes / totalBytes * 100 + fileContribution)));
|
|
}
|
|
},
|
|
}).then(() => {
|
|
uploadedBytes += files[i].size;
|
|
})
|
|
)
|
|
);
|
|
|
|
// Confirm all uploads
|
|
const keys = presignedUrls.map((p) => p.key);
|
|
await confirmUploads(keys);
|
|
|
|
// Find the original variant key (no suffix) for database storage
|
|
const originalIdx = resizedImages.findIndex((r) => r.variant.size === "original");
|
|
const baseKey = presignedUrls[originalIdx]?.key || presignedUrls[0].key;
|
|
|
|
if (onProgress) onProgress(100);
|
|
|
|
return {
|
|
baseKey,
|
|
publicUrl: getPublicImageUrl(baseKey),
|
|
variants: keys,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Upload multiple images with all size variants
|
|
* Returns array of base keys for database storage
|
|
*/
|
|
export async function uploadImagesWithVariants(
|
|
uploadType: UploadType,
|
|
files: File[],
|
|
options: UploadWithResizeOptions = {}
|
|
): Promise<{ baseKey: string; publicUrl: string }[]> {
|
|
if (files.length === 0) return [];
|
|
|
|
const results = await Promise.all(
|
|
files.map((file) => uploadImageWithVariants(uploadType, file, options))
|
|
);
|
|
|
|
return results.map((r) => ({ baseKey: r.baseKey, publicUrl: r.publicUrl }));
|
|
}
|