Add ability to upload and manage service images
Integrates Uppy.js for file uploads, adds new API endpoints for requesting upload URLs, and updates UI components to support image uploads. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 804c1330-3360-45df-814d-221ee0d46866 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/JyUisd3 Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import { Router, type IRouter, type Request, type Response } from "express";
|
||||
import { Readable } from "stream";
|
||||
import {
|
||||
RequestUploadUrlBody,
|
||||
RequestUploadUrlResponse,
|
||||
} from "@workspace/api-zod";
|
||||
import { ObjectStorageService, ObjectNotFoundError } from "../lib/objectStorage";
|
||||
import { ObjectPermission } from "../lib/objectAcl";
|
||||
|
||||
const router: IRouter = Router();
|
||||
const objectStorageService = new ObjectStorageService();
|
||||
|
||||
/**
|
||||
* POST /storage/uploads/request-url
|
||||
*
|
||||
* Request a presigned URL for file upload.
|
||||
* The client sends JSON metadata (name, size, contentType) — NOT the file.
|
||||
* Then uploads the file directly to the returned presigned URL.
|
||||
*/
|
||||
router.post("/storage/uploads/request-url", async (req: Request, res: Response) => {
|
||||
const parsed = RequestUploadUrlBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: "Missing or invalid required fields" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { name, size, contentType } = parsed.data;
|
||||
|
||||
const uploadURL = await objectStorageService.getObjectEntityUploadURL();
|
||||
const objectPath = objectStorageService.normalizeObjectEntityPath(uploadURL);
|
||||
|
||||
res.json(
|
||||
RequestUploadUrlResponse.parse({
|
||||
uploadURL,
|
||||
objectPath,
|
||||
metadata: { name, size, contentType },
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
req.log.error({ err: error }, "Error generating upload URL");
|
||||
res.status(500).json({ error: "Failed to generate upload URL" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /storage/public-objects/*
|
||||
*
|
||||
* Serve public assets from PUBLIC_OBJECT_SEARCH_PATHS.
|
||||
* These are unconditionally public — no authentication or ACL checks.
|
||||
* IMPORTANT: Always provide this endpoint when object storage is set up.
|
||||
*/
|
||||
router.get("/storage/public-objects/*filePath", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const raw = req.params.filePath;
|
||||
const filePath = Array.isArray(raw) ? raw.join("/") : raw;
|
||||
const file = await objectStorageService.searchPublicObject(filePath);
|
||||
if (!file) {
|
||||
res.status(404).json({ error: "File not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await objectStorageService.downloadObject(file);
|
||||
|
||||
res.status(response.status);
|
||||
response.headers.forEach((value, key) => res.setHeader(key, value));
|
||||
|
||||
if (response.body) {
|
||||
const nodeStream = Readable.fromWeb(response.body as ReadableStream<Uint8Array>);
|
||||
nodeStream.pipe(res);
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
} catch (error) {
|
||||
req.log.error({ err: error }, "Error serving public object");
|
||||
res.status(500).json({ error: "Failed to serve public object" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /storage/objects/*
|
||||
*
|
||||
* Serve object entities from PRIVATE_OBJECT_DIR.
|
||||
* These are served from a separate path from /public-objects and can optionally
|
||||
* be protected with authentication or ACL checks based on the use case.
|
||||
*/
|
||||
router.get("/storage/objects/*path", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const raw = req.params.path;
|
||||
const wildcardPath = Array.isArray(raw) ? raw.join("/") : raw;
|
||||
const objectPath = `/objects/${wildcardPath}`;
|
||||
const objectFile = await objectStorageService.getObjectEntityFile(objectPath);
|
||||
|
||||
// --- Protected route example (uncomment when using replit-auth) ---
|
||||
// if (!req.isAuthenticated()) {
|
||||
// res.status(401).json({ error: "Unauthorized" });
|
||||
// return;
|
||||
// }
|
||||
// const canAccess = await objectStorageService.canAccessObjectEntity({
|
||||
// userId: req.user.id,
|
||||
// objectFile,
|
||||
// requestedPermission: ObjectPermission.READ,
|
||||
// });
|
||||
// if (!canAccess) {
|
||||
// res.status(403).json({ error: "Forbidden" });
|
||||
// return;
|
||||
// }
|
||||
|
||||
const response = await objectStorageService.downloadObject(objectFile);
|
||||
|
||||
res.status(response.status);
|
||||
response.headers.forEach((value, key) => res.setHeader(key, value));
|
||||
|
||||
if (response.body) {
|
||||
const nodeStream = Readable.fromWeb(response.body as ReadableStream<Uint8Array>);
|
||||
nodeStream.pipe(res);
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ObjectNotFoundError) {
|
||||
req.log.warn({ err: error }, "Object not found");
|
||||
res.status(404).json({ error: "Object not found" });
|
||||
return;
|
||||
}
|
||||
req.log.error({ err: error }, "Error serving object");
|
||||
res.status(500).json({ error: "Failed to serve object" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user