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.
This commit is contained in:
Riyadh
2026-04-20 10:55:11 +00:00
parent ef76e2dc15
commit 0efc4032be
23 changed files with 3479 additions and 358 deletions
+2
View File
@@ -10,6 +10,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@google-cloud/storage": "^7.19.0",
"@workspace/api-zod": "workspace:*",
"@workspace/db": "workspace:*",
"bcryptjs": "^3.0.3",
@@ -19,6 +20,7 @@
"drizzle-orm": "catalog:",
"express": "^5",
"express-session": "^1.19.0",
"google-auth-library": "^10.6.2",
"pino": "^9",
"pino-http": "^10",
"socket.io": "^4.8.3"
+137
View File
@@ -0,0 +1,137 @@
import { File } from "@google-cloud/storage";
const ACL_POLICY_METADATA_KEY = "custom:aclPolicy";
// Can be flexibly defined according to the use case.
//
// Examples:
// - USER_LIST: the users from a list stored in the database;
// - EMAIL_DOMAIN: the users whose email is in a specific domain;
// - GROUP_MEMBER: the users who are members of a specific group;
// - SUBSCRIBER: the users who are subscribers of a specific service / content
// creator.
export enum ObjectAccessGroupType {}
export interface ObjectAccessGroup {
type: ObjectAccessGroupType;
// The logic id that identifies qualified group members. Format depends on the
// ObjectAccessGroupType — e.g. a user-list DB id, an email domain, a group id.
id: string;
}
export enum ObjectPermission {
READ = "read",
WRITE = "write",
}
export interface ObjectAclRule {
group: ObjectAccessGroup;
permission: ObjectPermission;
}
// Stored as object custom metadata under "custom:aclPolicy" (JSON string).
export interface ObjectAclPolicy {
owner: string;
visibility: "public" | "private";
aclRules?: Array<ObjectAclRule>;
}
function isPermissionAllowed(
requested: ObjectPermission,
granted: ObjectPermission,
): boolean {
if (requested === ObjectPermission.READ) {
return [ObjectPermission.READ, ObjectPermission.WRITE].includes(granted);
}
return granted === ObjectPermission.WRITE;
}
abstract class BaseObjectAccessGroup implements ObjectAccessGroup {
constructor(
public readonly type: ObjectAccessGroupType,
public readonly id: string,
) {}
public abstract hasMember(userId: string): Promise<boolean>;
}
function createObjectAccessGroup(
group: ObjectAccessGroup,
): BaseObjectAccessGroup {
switch (group.type) {
// Implement per access group type, e.g.:
// case "USER_LIST":
// return new UserListAccessGroup(group.id);
default:
throw new Error(`Unknown access group type: ${group.type}`);
}
}
export async function setObjectAclPolicy(
objectFile: File,
aclPolicy: ObjectAclPolicy,
): Promise<void> {
const [exists] = await objectFile.exists();
if (!exists) {
throw new Error(`Object not found: ${objectFile.name}`);
}
await objectFile.setMetadata({
metadata: {
[ACL_POLICY_METADATA_KEY]: JSON.stringify(aclPolicy),
},
});
}
export async function getObjectAclPolicy(
objectFile: File,
): Promise<ObjectAclPolicy | null> {
const [metadata] = await objectFile.getMetadata();
const aclPolicy = metadata?.metadata?.[ACL_POLICY_METADATA_KEY];
if (!aclPolicy) {
return null;
}
return JSON.parse(aclPolicy as string);
}
export async function canAccessObject({
userId,
objectFile,
requestedPermission,
}: {
userId?: string;
objectFile: File;
requestedPermission: ObjectPermission;
}): Promise<boolean> {
const aclPolicy = await getObjectAclPolicy(objectFile);
if (!aclPolicy) {
return false;
}
if (
aclPolicy.visibility === "public" &&
requestedPermission === ObjectPermission.READ
) {
return true;
}
if (!userId) {
return false;
}
if (aclPolicy.owner === userId) {
return true;
}
for (const rule of aclPolicy.aclRules || []) {
const accessGroup = createObjectAccessGroup(rule.group);
if (
(await accessGroup.hasMember(userId)) &&
isPermissionAllowed(requestedPermission, rule.permission)
) {
return true;
}
}
return false;
}
@@ -0,0 +1,267 @@
import { Storage, File } from "@google-cloud/storage";
import { Readable } from "stream";
import { randomUUID } from "crypto";
import {
ObjectAclPolicy,
ObjectPermission,
canAccessObject,
getObjectAclPolicy,
setObjectAclPolicy,
} from "./objectAcl";
const REPLIT_SIDECAR_ENDPOINT = "http://127.0.0.1:1106";
export const objectStorageClient = new Storage({
credentials: {
audience: "replit",
subject_token_type: "access_token",
token_url: `${REPLIT_SIDECAR_ENDPOINT}/token`,
type: "external_account",
credential_source: {
url: `${REPLIT_SIDECAR_ENDPOINT}/credential`,
format: {
type: "json",
subject_token_field_name: "access_token",
},
},
universe_domain: "googleapis.com",
},
projectId: "",
});
export class ObjectNotFoundError extends Error {
constructor() {
super("Object not found");
this.name = "ObjectNotFoundError";
Object.setPrototypeOf(this, ObjectNotFoundError.prototype);
}
}
export class ObjectStorageService {
constructor() {}
getPublicObjectSearchPaths(): Array<string> {
const pathsStr = process.env.PUBLIC_OBJECT_SEARCH_PATHS || "";
const paths = Array.from(
new Set(
pathsStr
.split(",")
.map((path) => path.trim())
.filter((path) => path.length > 0)
)
);
if (paths.length === 0) {
throw new Error(
"PUBLIC_OBJECT_SEARCH_PATHS not set. Create a bucket in 'Object Storage' " +
"tool and set PUBLIC_OBJECT_SEARCH_PATHS env var (comma-separated paths)."
);
}
return paths;
}
getPrivateObjectDir(): string {
const dir = process.env.PRIVATE_OBJECT_DIR || "";
if (!dir) {
throw new Error(
"PRIVATE_OBJECT_DIR not set. Create a bucket in 'Object Storage' " +
"tool and set PRIVATE_OBJECT_DIR env var."
);
}
return dir;
}
async searchPublicObject(filePath: string): Promise<File | null> {
for (const searchPath of this.getPublicObjectSearchPaths()) {
const fullPath = `${searchPath}/${filePath}`;
const { bucketName, objectName } = parseObjectPath(fullPath);
const bucket = objectStorageClient.bucket(bucketName);
const file = bucket.file(objectName);
const [exists] = await file.exists();
if (exists) {
return file;
}
}
return null;
}
async downloadObject(file: File, cacheTtlSec: number = 3600): Promise<Response> {
const [metadata] = await file.getMetadata();
const aclPolicy = await getObjectAclPolicy(file);
const isPublic = aclPolicy?.visibility === "public";
const nodeStream = file.createReadStream();
const webStream = Readable.toWeb(nodeStream) as ReadableStream;
const headers: Record<string, string> = {
"Content-Type": (metadata.contentType as string) || "application/octet-stream",
"Cache-Control": `${isPublic ? "public" : "private"}, max-age=${cacheTtlSec}`,
};
if (metadata.size) {
headers["Content-Length"] = String(metadata.size);
}
return new Response(webStream, { headers });
}
async getObjectEntityUploadURL(): Promise<string> {
const privateObjectDir = this.getPrivateObjectDir();
if (!privateObjectDir) {
throw new Error(
"PRIVATE_OBJECT_DIR not set. Create a bucket in 'Object Storage' " +
"tool and set PRIVATE_OBJECT_DIR env var."
);
}
const objectId = randomUUID();
const fullPath = `${privateObjectDir}/uploads/${objectId}`;
const { bucketName, objectName } = parseObjectPath(fullPath);
return signObjectURL({
bucketName,
objectName,
method: "PUT",
ttlSec: 900,
});
}
async getObjectEntityFile(objectPath: string): Promise<File> {
if (!objectPath.startsWith("/objects/")) {
throw new ObjectNotFoundError();
}
const parts = objectPath.slice(1).split("/");
if (parts.length < 2) {
throw new ObjectNotFoundError();
}
const entityId = parts.slice(1).join("/");
let entityDir = this.getPrivateObjectDir();
if (!entityDir.endsWith("/")) {
entityDir = `${entityDir}/`;
}
const objectEntityPath = `${entityDir}${entityId}`;
const { bucketName, objectName } = parseObjectPath(objectEntityPath);
const bucket = objectStorageClient.bucket(bucketName);
const objectFile = bucket.file(objectName);
const [exists] = await objectFile.exists();
if (!exists) {
throw new ObjectNotFoundError();
}
return objectFile;
}
normalizeObjectEntityPath(rawPath: string): string {
if (!rawPath.startsWith("https://storage.googleapis.com/")) {
return rawPath;
}
const url = new URL(rawPath);
const rawObjectPath = url.pathname;
let objectEntityDir = this.getPrivateObjectDir();
if (!objectEntityDir.endsWith("/")) {
objectEntityDir = `${objectEntityDir}/`;
}
if (!rawObjectPath.startsWith(objectEntityDir)) {
return rawObjectPath;
}
const entityId = rawObjectPath.slice(objectEntityDir.length);
return `/objects/${entityId}`;
}
async trySetObjectEntityAclPolicy(
rawPath: string,
aclPolicy: ObjectAclPolicy
): Promise<string> {
const normalizedPath = this.normalizeObjectEntityPath(rawPath);
if (!normalizedPath.startsWith("/")) {
return normalizedPath;
}
const objectFile = await this.getObjectEntityFile(normalizedPath);
await setObjectAclPolicy(objectFile, aclPolicy);
return normalizedPath;
}
async canAccessObjectEntity({
userId,
objectFile,
requestedPermission,
}: {
userId?: string;
objectFile: File;
requestedPermission?: ObjectPermission;
}): Promise<boolean> {
return canAccessObject({
userId,
objectFile,
requestedPermission: requestedPermission ?? ObjectPermission.READ,
});
}
}
function parseObjectPath(path: string): {
bucketName: string;
objectName: string;
} {
if (!path.startsWith("/")) {
path = `/${path}`;
}
const pathParts = path.split("/");
if (pathParts.length < 3) {
throw new Error("Invalid path: must contain at least a bucket name");
}
const bucketName = pathParts[1];
const objectName = pathParts.slice(2).join("/");
return {
bucketName,
objectName,
};
}
async function signObjectURL({
bucketName,
objectName,
method,
ttlSec,
}: {
bucketName: string;
objectName: string;
method: "GET" | "PUT" | "DELETE" | "HEAD";
ttlSec: number;
}): Promise<string> {
const request = {
bucket_name: bucketName,
object_name: objectName,
method,
expires_at: new Date(Date.now() + ttlSec * 1000).toISOString(),
};
const response = await fetch(
`${REPLIT_SIDECAR_ENDPOINT}/object-storage/signed-object-url`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
signal: AbortSignal.timeout(30_000),
}
);
if (!response.ok) {
throw new Error(
`Failed to sign object URL, errorcode: ${response.status}, ` +
`make sure you're running on Replit`
);
}
const { signed_url: signedURL } = await response.json();
return signedURL;
}
+2
View File
@@ -7,6 +7,7 @@ import conversationsRouter from "./conversations";
import notificationsRouter from "./notifications";
import usersRouter from "./users";
import statsRouter from "./stats";
import storageRouter from "./storage";
const router: IRouter = Router();
@@ -18,5 +19,6 @@ router.use(conversationsRouter);
router.use(notificationsRouter);
router.use(usersRouter);
router.use(statsRouter);
router.use(storageRouter);
export default router;
+131
View File
@@ -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;