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:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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