s3
This commit is contained in:
195
frontend/src/services/uploadService.ts
Normal file
195
frontend/src/services/uploadService.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import api from "./api";
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload multiple files to S3 (complete flow)
|
||||
*/
|
||||
export async function uploadFiles(
|
||||
uploadType: UploadType,
|
||||
files: File[],
|
||||
options: UploadOptions = {}
|
||||
): Promise<{ key: string; publicUrl: string }[]> {
|
||||
if (files.length === 0) return [];
|
||||
|
||||
// Get presigned URLs for all files
|
||||
const presignedUrls = await getPresignedUrls(uploadType, files);
|
||||
|
||||
// Upload all files in parallel
|
||||
await Promise.all(
|
||||
files.map((file, i) =>
|
||||
uploadToS3(file, presignedUrls[i].uploadUrl, options)
|
||||
)
|
||||
);
|
||||
|
||||
// Confirm all uploads
|
||||
const keys = presignedUrls.map((p) => p.key);
|
||||
const { confirmed, total } = await confirmUploads(keys);
|
||||
|
||||
if (confirmed.length < total) {
|
||||
console.warn(`${total - confirmed.length} uploads failed verification`);
|
||||
}
|
||||
|
||||
return presignedUrls
|
||||
.filter((p) => confirmed.includes(p.key))
|
||||
.map((p) => ({ key: p.key, publicUrl: p.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;
|
||||
}
|
||||
Reference in New Issue
Block a user