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;
+7
View File
@@ -76,5 +76,12 @@
"vite": "catalog:",
"wouter": "^3.3.5",
"zod": "catalog:"
},
"dependencies": {
"@uppy/aws-s3": "^5.1.0",
"@uppy/core": "^5.2.0",
"@uppy/dashboard": "^5.1.1",
"@uppy/react": "^5.2.0",
"@workspace/object-storage-web": "workspace:*"
}
}
+5
View File
@@ -80,6 +80,11 @@
"servicePrice": "السعر",
"serviceAvailable": "متاح؟",
"serviceImageUrl": "رابط الصورة",
"serviceImage": "صورة الخدمة",
"uploadImage": "رفع صورة",
"replaceImage": "استبدال الصورة",
"uploading": "جاري الرفع...",
"uploadFailed": "فشل رفع الصورة",
"users": "المستخدمين",
"username": "اسم المستخدم",
"email": "البريد الإلكتروني",
+5
View File
@@ -80,6 +80,11 @@
"servicePrice": "Price",
"serviceAvailable": "Available?",
"serviceImageUrl": "Image URL",
"serviceImage": "Service Image",
"uploadImage": "Upload Image",
"replaceImage": "Replace Image",
"uploading": "Uploading...",
"uploadFailed": "Image upload failed",
"users": "Users",
"username": "Username",
"email": "Email",
+83 -13
View File
@@ -20,7 +20,8 @@ import {
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/contexts/AuthContext";
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield } from "lucide-react";
import { useUpload } from "@workspace/object-storage-web";
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -221,7 +222,6 @@ export default function AdminPage() {
{ field: "descriptionAr", label: t("admin.serviceDescriptionAr") },
{ field: "descriptionEn", label: t("admin.serviceDescriptionEn") },
{ field: "price", label: t("admin.servicePrice") },
{ field: "imageUrl", label: t("admin.serviceImageUrl") },
] as { field: keyof ServiceForm; label: string }[]
).map(({ field, label }) => (
<div key={field} className="space-y-1">
@@ -230,20 +230,22 @@ export default function AdminPage() {
value={String(editingService.form[field])}
onChange={(e) => setEditingService({ ...editingService, form: { ...editingService.form, [field]: e.target.value } })}
className="bg-white/70 border-slate-200"
placeholder={field === "imageUrl" ? "https://..." : undefined}
/>
</div>
))}
{editingService.form.imageUrl && (
<div className="rounded-xl overflow-hidden border border-slate-200 bg-slate-50">
<img
src={editingService.form.imageUrl}
alt=""
className="w-full h-32 object-cover"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
/>
</div>
)}
<div className="space-y-2">
<Label>{t("admin.serviceImage")}</Label>
<ServiceImageUploader
value={editingService.form.imageUrl}
onChange={(url) =>
setEditingService({
...editingService,
form: { ...editingService.form, imageUrl: url },
})
}
/>
</div>
<div className="flex items-center justify-between">
<Label>{t("admin.serviceAvailable")}</Label>
<Switch
@@ -491,3 +493,71 @@ export default function AdminPage() {
</div>
);
}
function ServiceImageUploader({
value,
onChange,
}: {
value: string;
onChange: (url: string) => void;
}) {
const { t } = useTranslation();
const { toast } = useToast();
const { uploadFile, isUploading, progress } = useUpload({
onSuccess: (resp) => {
onChange(`/api/storage${resp.objectPath}`);
},
onError: (err) => {
toast({ title: t("admin.uploadFailed"), description: err.message, variant: "destructive" });
},
});
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<label className="cursor-pointer">
<input
type="file"
accept="image/*"
disabled={isUploading}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
void uploadFile(file);
}
e.currentTarget.value = "";
}}
/>
<span className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm text-slate-700 hover:bg-slate-50 transition">
{isUploading ? <Loader2 size={16} className="animate-spin" /> : <Upload size={16} />}
{isUploading
? `${t("admin.uploading")} ${progress}%`
: value
? t("admin.replaceImage")
: t("admin.uploadImage")}
</span>
</label>
{value && !isUploading && (
<button
type="button"
onClick={() => onChange("")}
className="px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm text-slate-500 hover:text-destructive hover:border-destructive/40"
>
<Trash2 size={14} />
</button>
)}
</div>
{value && (
<div className="rounded-xl overflow-hidden border border-slate-200 bg-slate-50">
<img
src={value}
alt=""
className="w-full h-32 object-cover"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
/>
</div>
)}
</div>
);
}
+3
View File
@@ -17,6 +17,9 @@
"references": [
{
"path": "../../lib/api-client-react"
},
{
"path": "../../lib/object-storage-web"
}
]
}
@@ -257,6 +257,21 @@ export interface Notification {
createdAt: string;
}
export interface RequestUploadUrlBody {
/** @minLength 1 */
name: string;
/** @minimum 1 */
size: number;
/** @minLength 1 */
contentType: string;
}
export interface RequestUploadUrlResponse {
uploadURL: string;
objectPath: string;
metadata?: RequestUploadUrlBody;
}
export interface HomeStats {
totalApps: number;
totalServices: number;
+88
View File
@@ -30,6 +30,8 @@ import type {
MessageWithSender,
Notification,
RegisterBody,
RequestUploadUrlBody,
RequestUploadUrlResponse,
SendMessageBody,
Service,
ServiceCategory,
@@ -924,6 +926,92 @@ export const useDeleteApp = <
return useMutation(getDeleteAppMutationOptions(options));
};
/**
* @summary Request a presigned URL for file upload
*/
export const getRequestUploadUrlUrl = () => {
return `/api/storage/uploads/request-url`;
};
export const requestUploadUrl = async (
requestUploadUrlBody: RequestUploadUrlBody,
options?: RequestInit,
): Promise<RequestUploadUrlResponse> => {
return customFetch<RequestUploadUrlResponse>(getRequestUploadUrlUrl(), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(requestUploadUrlBody),
});
};
export const getRequestUploadUrlMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof requestUploadUrl>>,
TError,
{ data: BodyType<RequestUploadUrlBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof requestUploadUrl>>,
TError,
{ data: BodyType<RequestUploadUrlBody> },
TContext
> => {
const mutationKey = ["requestUploadUrl"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof requestUploadUrl>>,
{ data: BodyType<RequestUploadUrlBody> }
> = (props) => {
const { data } = props ?? {};
return requestUploadUrl(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type RequestUploadUrlMutationResult = NonNullable<
Awaited<ReturnType<typeof requestUploadUrl>>
>;
export type RequestUploadUrlMutationBody = BodyType<RequestUploadUrlBody>;
export type RequestUploadUrlMutationError = ErrorType<ErrorResponse>;
/**
* @summary Request a presigned URL for file upload
*/
export const useRequestUploadUrl = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof requestUploadUrl>>,
TError,
{ data: BodyType<RequestUploadUrlBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof requestUploadUrl>>,
TError,
{ data: BodyType<RequestUploadUrlBody> },
TContext
> => {
return useMutation(getRequestUploadUrlMutationOptions(options));
};
/**
* @summary List all services
*/
+54
View File
@@ -26,6 +26,8 @@ tags:
description: User management
- name: stats
description: Dashboard stats
- name: storage
description: Object storage upload and serving endpoints
paths:
/healthz:
@@ -234,6 +236,32 @@ paths:
"204":
description: Deleted
# Storage
/storage/uploads/request-url:
post:
operationId: requestUploadUrl
tags: [storage]
summary: Request a presigned URL for file upload
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RequestUploadUrlBody"
responses:
"200":
description: Presigned upload URL generated
content:
application/json:
schema:
$ref: "#/components/schemas/RequestUploadUrlResponse"
"400":
description: Invalid input
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
# Services
/services:
get:
@@ -1112,6 +1140,32 @@ components:
- isRead
- createdAt
RequestUploadUrlBody:
type: object
required: [name, size, contentType]
properties:
name:
type: string
minLength: 1
size:
type: integer
minimum: 1
contentType:
type: string
minLength: 1
RequestUploadUrlResponse:
type: object
required: [uploadURL, objectPath]
properties:
uploadURL:
type: string
format: uri
objectPath:
type: string
metadata:
$ref: "#/components/schemas/RequestUploadUrlBody"
HomeStats:
type: object
properties:
+22
View File
@@ -197,6 +197,28 @@ export const DeleteAppParams = zod.object({
id: zod.coerce.number(),
});
/**
* @summary Request a presigned URL for file upload
*/
export const RequestUploadUrlBody = zod.object({
name: zod.string().min(1),
size: zod.number().min(1),
contentType: zod.string().min(1),
});
export const RequestUploadUrlResponse = zod.object({
uploadURL: zod.string().url(),
objectPath: zod.string(),
metadata: zod
.object({
name: zod.string().min(1),
size: zod.number().min(1),
contentType: zod.string().min(1),
})
.optional(),
});
/**
* @summary List all services
*/
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@workspace/object-storage-web",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"peerDependencies": {
"react": ">=18"
},
"devDependencies": {
"@types/react": "catalog:",
"@uppy/aws-s3": "^5.1.0",
"@uppy/core": "^5.2.0",
"@uppy/dashboard": "^5.1.1",
"@uppy/react": "^5.2.0",
"react": "catalog:",
"react-dom": "catalog:"
}
}
@@ -0,0 +1,106 @@
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import Uppy from "@uppy/core";
import type { UppyFile, UploadResult } from "@uppy/core";
import DashboardModal from "@uppy/react/dashboard-modal";
import "@uppy/core/css/style.min.css";
import "@uppy/dashboard/css/style.min.css";
import AwsS3 from "@uppy/aws-s3";
interface ObjectUploaderProps {
maxNumberOfFiles?: number;
maxFileSize?: number;
/**
* Function to get upload parameters for each file.
* IMPORTANT: This receives the file object - use file.name, file.size, file.type
* to request per-file presigned URLs from your backend.
*/
onGetUploadParameters: (
file: UppyFile<Record<string, unknown>, Record<string, unknown>>
) => Promise<{
method: "PUT";
url: string;
headers?: Record<string, string>;
}>;
onComplete?: (
result: UploadResult<Record<string, unknown>, Record<string, unknown>>
) => void;
buttonClassName?: string;
children: ReactNode;
}
/**
* A file upload component that renders as a button and provides a modal interface for
* file management.
*
* Features:
* - Renders as a customizable button that opens a file upload modal
* - Provides a modal interface for:
* - File selection
* - File preview
* - Upload progress tracking
* - Upload status display
*
* The component uses Uppy v5 under the hood to handle all file upload functionality.
* All file management features are automatically handled by the Uppy dashboard modal.
*
* @param props - Component props
* @param props.maxNumberOfFiles - Maximum number of files allowed to be uploaded
* (default: 1)
* @param props.maxFileSize - Maximum file size in bytes (default: 10MB)
* @param props.onGetUploadParameters - Function to get upload parameters for each file.
* Receives the UppyFile object with file.name, file.size, file.type properties.
* Use these to request per-file presigned URLs from your backend. Returns method,
* url, and optional headers for the upload request.
* @param props.onComplete - Callback function called when upload is complete. Typically
* used to make post-upload API calls to update server state and set object ACL
* policies.
* @param props.buttonClassName - Optional CSS class name for the button
* @param props.children - Content to be rendered inside the button
*/
export function ObjectUploader({
maxNumberOfFiles = 1,
maxFileSize = 10485760, // 10MB default
onGetUploadParameters,
onComplete,
buttonClassName,
children,
}: ObjectUploaderProps) {
const onCompleteRef = useRef(onComplete);
const onGetUploadParametersRef = useRef(onGetUploadParameters);
useEffect(() => { onCompleteRef.current = onComplete; }, [onComplete]);
useEffect(() => { onGetUploadParametersRef.current = onGetUploadParameters; }, [onGetUploadParameters]);
const [showModal, setShowModal] = useState(false);
const [uppy] = useState(() =>
new Uppy({
restrictions: {
maxNumberOfFiles,
maxFileSize,
},
autoProceed: false,
})
.use(AwsS3, {
shouldUseMultipart: false,
getUploadParameters: (file) => onGetUploadParametersRef.current(file),
})
.on("complete", (result) => {
onCompleteRef.current?.(result);
})
);
return (
<div>
<button onClick={() => setShowModal(true)} className={buttonClassName}>
{children}
</button>
<DashboardModal
uppy={uppy}
open={showModal}
onRequestClose={() => setShowModal(false)}
proudlyDisplayPoweredByUppy={false}
/>
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { ObjectUploader } from "./ObjectUploader";
export { useUpload } from "./use-upload";
+172
View File
@@ -0,0 +1,172 @@
import { useState, useCallback } from "react";
import type { UppyFile } from "@uppy/core";
interface UploadMetadata {
name: string;
size: number;
contentType: string;
}
interface UploadResponse {
uploadURL: string;
objectPath: string;
metadata: UploadMetadata;
}
interface UseUploadOptions {
/** Base path where object storage routes are mounted (default: "/api/storage") */
basePath?: string;
onSuccess?: (response: UploadResponse) => void;
onError?: (error: Error) => void;
}
/**
* React hook for handling file uploads with presigned URLs.
*
* This hook implements the two-step presigned URL upload flow:
* 1. Request a presigned URL from your backend (sends JSON metadata, NOT the file)
* 2. Upload the file directly to the presigned URL
*
* @example
* ```tsx
* function FileUploader() {
* const { uploadFile, isUploading, error } = useUpload({
* onSuccess: (response) => {
* console.log("Uploaded to:", response.objectPath);
* },
* });
*
* const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
* const file = e.target.files?.[0];
* if (file) {
* await uploadFile(file);
* }
* };
*
* return (
* <div>
* <input type="file" onChange={handleFileChange} disabled={isUploading} />
* {isUploading && <p>Uploading...</p>}
* {error && <p>Error: {error.message}</p>}
* </div>
* );
* }
* ```
*/
export function useUpload(options: UseUploadOptions = {}) {
const basePath = options.basePath ?? "/api/storage";
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [progress, setProgress] = useState(0);
const requestUploadUrl = useCallback(
async (file: File): Promise<UploadResponse> => {
const response = await fetch(`${basePath}/uploads/request-url`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: file.name,
size: file.size,
contentType: file.type || "application/octet-stream",
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || "Failed to get upload URL");
}
return response.json();
},
[]
);
const uploadToPresignedUrl = useCallback(
async (file: File, uploadURL: string): Promise<void> => {
const response = await fetch(uploadURL, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type || "application/octet-stream",
},
});
if (!response.ok) {
throw new Error("Failed to upload file to storage");
}
},
[]
);
const uploadFile = useCallback(
async (file: File): Promise<UploadResponse | null> => {
setIsUploading(true);
setError(null);
setProgress(0);
try {
setProgress(10);
const uploadResponse = await requestUploadUrl(file);
setProgress(30);
await uploadToPresignedUrl(file, uploadResponse.uploadURL);
setProgress(100);
options.onSuccess?.(uploadResponse);
return uploadResponse;
} catch (err) {
const error = err instanceof Error ? err : new Error("Upload failed");
setError(error);
options.onError?.(error);
return null;
} finally {
setIsUploading(false);
}
},
[requestUploadUrl, uploadToPresignedUrl, options]
);
const getUploadParameters = useCallback(
async (
file: UppyFile<Record<string, unknown>, Record<string, unknown>>
): Promise<{
method: "PUT";
url: string;
headers?: Record<string, string>;
}> => {
const response = await fetch(`${basePath}/uploads/request-url`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: file.name,
size: file.size,
contentType: file.type || "application/octet-stream",
}),
});
if (!response.ok) {
throw new Error("Failed to get upload URL");
}
const data = await response.json();
return {
method: "PUT",
url: data.uploadURL,
headers: { "Content-Type": file.type || "application/octet-stream" },
};
},
[]
);
return {
uploadFile,
getUploadParameters,
isUploading,
error,
progress,
};
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",
"lib": ["esnext", "dom", "dom.iterable"]
},
"include": ["src"]
}
+6
View File
@@ -12,5 +12,11 @@
"devDependencies": {
"typescript": "~5.9.2",
"prettier": "^3.8.1"
},
"pnpm": {
"overrides": {
"react": "19.1.0",
"react-dom": "19.1.0"
}
}
}
+2250 -223
View File
File diff suppressed because it is too large Load Diff
+88 -122
View File
@@ -1,45 +1,11 @@
# ============================================================================
# SECURITY: Minimum release age for npm packages (supply-chain attack defense)
# ============================================================================
#
# This setting requires that any npm package version must have been published
# for at least 1 day (1440 minutes) before pnpm will allow installing it.
# This is a critical defense against supply-chain attacks. In most cases,
# malicious npm releases are discovered and pulled within hours, so a 1-day
# delay provides a strong safety buffer.
#
# DO NOT DISABLE THIS SETTING. Removing or setting it to 0 is considered
# extremely dangerous and leaves the entire workspace vulnerable to supply-
# chain attacks, which have been the #1 vector for npm ecosystem compromises.
#
# If you absolutely need to install a package before the 1-day window has
# passed (e.g. an urgent security bugfix), you can add it to the
# `minimumReleaseAgeExclude` allowlist below. Only consider doing this for
# packages released by trusted organizations with an impeccable security
# posture (e.g. Replit packsges, react from Meta, typescript from Microsoft). Even then,
# remove the exclusion once the 1-day window has passed.
#
# Example:
# minimumReleaseAgeExclude:
# - react
# - typescript
#
# ============================================================================
minimumReleaseAge: 1440
minimumReleaseAgeExclude:
# Exclude @replit scoped packages from the minimum release age check.
# These are published by Replit and trusted — the supply-chain attack vector
# this setting guards against does not apply to our own packages.
- '@replit/*'
- stripe-replit-sync
packages:
- artifacts/*
- lib/*
- lib/integrations/*
- scripts
autoInstallPeers: false
catalog:
'@replit/vite-plugin-cartographer': ^0.5.1
'@replit/vite-plugin-dev-banner': ^0.1.1
@@ -55,9 +21,7 @@ catalog:
drizzle-orm: ^0.45.2
framer-motion: ^12.23.24
lucide-react: ^0.545.0
# Must be this exact version because expo requires it
react: 19.1.0
# Must be this exact version because expo requires it
react-dom: 19.1.0
tailwind-merge: ^3.3.1
tailwindcss: ^4.1.14
@@ -65,7 +29,11 @@ catalog:
vite: ^7.3.2
zod: ^3.25.76
autoInstallPeers: false
minimumReleaseAge: 1440
minimumReleaseAgeExclude:
- '@replit/*'
- stripe-replit-sync
onlyBuiltDependencies:
- '@swc/core'
@@ -74,86 +42,84 @@ onlyBuiltDependencies:
- unrs-resolver
overrides:
# replit uses linux-x64 only, we can exclude all other platforms
"esbuild>@esbuild/darwin-arm64": "-"
"esbuild>@esbuild/darwin-x64": "-"
"esbuild>@esbuild/freebsd-arm64": "-"
"esbuild>@esbuild/freebsd-x64": "-"
"esbuild>@esbuild/linux-arm": "-"
"esbuild>@esbuild/linux-arm64": "-"
"esbuild>@esbuild/linux-ia32": "-"
"esbuild>@esbuild/linux-loong64": "-"
"esbuild>@esbuild/linux-mips64el": "-"
"esbuild>@esbuild/linux-ppc64": "-"
"esbuild>@esbuild/linux-riscv64": "-"
"esbuild>@esbuild/linux-s390x": "-"
"esbuild>@esbuild/netbsd-arm64": "-"
"esbuild>@esbuild/netbsd-x64": "-"
"esbuild>@esbuild/openbsd-arm64": "-"
"esbuild>@esbuild/openbsd-x64": "-"
"esbuild>@esbuild/sunos-x64": "-"
"esbuild>@esbuild/win32-arm64": "-"
"esbuild>@esbuild/win32-ia32": "-"
"esbuild>@esbuild/win32-x64": "-"
"esbuild>@esbuild/aix-ppc64": '-'
"esbuild>@esbuild/android-arm": '-'
"esbuild>@esbuild/android-arm64": '-'
"esbuild>@esbuild/android-x64": '-'
"esbuild>@esbuild/openharmony-arm64": '-'
"lightningcss>lightningcss-android-arm64": "-"
"lightningcss>lightningcss-darwin-arm64": "-"
"lightningcss>lightningcss-darwin-x64": "-"
"lightningcss>lightningcss-freebsd-x64": "-"
"lightningcss>lightningcss-linux-arm-gnueabihf": "-"
"lightningcss>lightningcss-linux-arm64-gnu": "-"
"lightningcss>lightningcss-linux-arm64-musl": "-"
"lightningcss>lightningcss-linux-x64-musl": "-"
"lightningcss>lightningcss-win32-arm64-msvc": "-"
"lightningcss>lightningcss-win32-x64-msvc": "-"
"@tailwindcss/oxide>@tailwindcss/oxide-android-arm64": "-"
"@tailwindcss/oxide>@tailwindcss/oxide-darwin-arm64": "-"
"@tailwindcss/oxide>@tailwindcss/oxide-darwin-x64": "-"
"@tailwindcss/oxide>@tailwindcss/oxide-freebsd-x64": "-"
"@tailwindcss/oxide>@tailwindcss/oxide-linux-arm-gnueabihf": "-"
"@tailwindcss/oxide>@tailwindcss/oxide-linux-arm64-gnu": "-"
"@tailwindcss/oxide>@tailwindcss/oxide-linux-arm64-musl": "-"
"@tailwindcss/oxide>@tailwindcss/oxide-win32-arm64-msvc": "-"
"@tailwindcss/oxide>@tailwindcss/oxide-win32-x64-msvc": "-"
"@tailwindcss/oxide>@tailwindcss/oxide-linux-x64-musl": "-"
"rollup>@rollup/rollup-android-arm-eabi": "-"
"rollup>@rollup/rollup-android-arm64": "-"
"rollup>@rollup/rollup-darwin-arm64": "-"
"rollup>@rollup/rollup-darwin-x64": "-"
"rollup>@rollup/rollup-freebsd-arm64": "-"
"rollup>@rollup/rollup-freebsd-x64": "-"
"rollup>@rollup/rollup-linux-arm-gnueabihf": "-"
"rollup>@rollup/rollup-linux-arm-musleabihf": "-"
"rollup>@rollup/rollup-linux-arm64-gnu": "-"
"rollup>@rollup/rollup-linux-arm64-musl": "-"
"rollup>@rollup/rollup-linux-loong64-gnu": "-"
"rollup>@rollup/rollup-linux-loong64-musl": "-"
"rollup>@rollup/rollup-linux-ppc64-gnu": "-"
"rollup>@rollup/rollup-linux-ppc64-musl": "-"
"rollup>@rollup/rollup-linux-riscv64-gnu": "-"
"rollup>@rollup/rollup-linux-riscv64-musl": "-"
"rollup>@rollup/rollup-linux-s390x-gnu": "-"
"rollup>@rollup/rollup-linux-x64-musl": "-"
"rollup>@rollup/rollup-openbsd-x64": "-"
"rollup>@rollup/rollup-openharmony-arm64": "-"
"rollup>@rollup/rollup-win32-arm64-msvc": "-"
"rollup>@rollup/rollup-win32-ia32-msvc": "-"
"rollup>@rollup/rollup-win32-x64-gnu": "-"
"rollup>@rollup/rollup-win32-x64-msvc": "-"
"@expo/ngrok-bin>@expo/ngrok-bin-darwin-arm64": "-"
"@expo/ngrok-bin>@expo/ngrok-bin-darwin-x64": "-"
"@expo/ngrok-bin>@expo/ngrok-bin-freebsd-ia32": "-"
"@expo/ngrok-bin>@expo/ngrok-bin-freebsd-x64": "-"
"@expo/ngrok-bin>@expo/ngrok-bin-linux-arm64": "-"
"@expo/ngrok-bin>@expo/ngrok-bin-linux-arm": "-"
"@expo/ngrok-bin>@expo/ngrok-bin-linux-ia32": "-"
"@expo/ngrok-bin>@expo/ngrok-bin-sunos-x64": "-"
"@expo/ngrok-bin>@expo/ngrok-bin-win32-ia32": "-"
"@expo/ngrok-bin>@expo/ngrok-bin-win32-x64": "-"
# drizzle-kit uses esbuild internally on an older version that's vulnerable, this overrides it
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0"
esbuild: "0.27.3"
'@esbuild-kit/esm-loader': npm:tsx@^4.21.0
'@expo/ngrok-bin>@expo/ngrok-bin-darwin-arm64': '-'
'@expo/ngrok-bin>@expo/ngrok-bin-darwin-x64': '-'
'@expo/ngrok-bin>@expo/ngrok-bin-freebsd-ia32': '-'
'@expo/ngrok-bin>@expo/ngrok-bin-freebsd-x64': '-'
'@expo/ngrok-bin>@expo/ngrok-bin-linux-arm': '-'
'@expo/ngrok-bin>@expo/ngrok-bin-linux-arm64': '-'
'@expo/ngrok-bin>@expo/ngrok-bin-linux-ia32': '-'
'@expo/ngrok-bin>@expo/ngrok-bin-sunos-x64': '-'
'@expo/ngrok-bin>@expo/ngrok-bin-win32-ia32': '-'
'@expo/ngrok-bin>@expo/ngrok-bin-win32-x64': '-'
'@tailwindcss/oxide>@tailwindcss/oxide-android-arm64': '-'
'@tailwindcss/oxide>@tailwindcss/oxide-darwin-arm64': '-'
'@tailwindcss/oxide>@tailwindcss/oxide-darwin-x64': '-'
'@tailwindcss/oxide>@tailwindcss/oxide-freebsd-x64': '-'
'@tailwindcss/oxide>@tailwindcss/oxide-linux-arm-gnueabihf': '-'
'@tailwindcss/oxide>@tailwindcss/oxide-linux-arm64-gnu': '-'
'@tailwindcss/oxide>@tailwindcss/oxide-linux-arm64-musl': '-'
'@tailwindcss/oxide>@tailwindcss/oxide-linux-x64-musl': '-'
'@tailwindcss/oxide>@tailwindcss/oxide-win32-arm64-msvc': '-'
'@tailwindcss/oxide>@tailwindcss/oxide-win32-x64-msvc': '-'
esbuild: 0.27.3
esbuild>@esbuild/aix-ppc64: '-'
esbuild>@esbuild/android-arm: '-'
esbuild>@esbuild/android-arm64: '-'
esbuild>@esbuild/android-x64: '-'
esbuild>@esbuild/darwin-arm64: '-'
esbuild>@esbuild/darwin-x64: '-'
esbuild>@esbuild/freebsd-arm64: '-'
esbuild>@esbuild/freebsd-x64: '-'
esbuild>@esbuild/linux-arm: '-'
esbuild>@esbuild/linux-arm64: '-'
esbuild>@esbuild/linux-ia32: '-'
esbuild>@esbuild/linux-loong64: '-'
esbuild>@esbuild/linux-mips64el: '-'
esbuild>@esbuild/linux-ppc64: '-'
esbuild>@esbuild/linux-riscv64: '-'
esbuild>@esbuild/linux-s390x: '-'
esbuild>@esbuild/netbsd-arm64: '-'
esbuild>@esbuild/netbsd-x64: '-'
esbuild>@esbuild/openbsd-arm64: '-'
esbuild>@esbuild/openbsd-x64: '-'
esbuild>@esbuild/openharmony-arm64: '-'
esbuild>@esbuild/sunos-x64: '-'
esbuild>@esbuild/win32-arm64: '-'
esbuild>@esbuild/win32-ia32: '-'
esbuild>@esbuild/win32-x64: '-'
lightningcss>lightningcss-android-arm64: '-'
lightningcss>lightningcss-darwin-arm64: '-'
lightningcss>lightningcss-darwin-x64: '-'
lightningcss>lightningcss-freebsd-x64: '-'
lightningcss>lightningcss-linux-arm-gnueabihf: '-'
lightningcss>lightningcss-linux-arm64-gnu: '-'
lightningcss>lightningcss-linux-arm64-musl: '-'
lightningcss>lightningcss-linux-x64-musl: '-'
lightningcss>lightningcss-win32-arm64-msvc: '-'
lightningcss>lightningcss-win32-x64-msvc: '-'
rollup>@rollup/rollup-android-arm-eabi: '-'
rollup>@rollup/rollup-android-arm64: '-'
rollup>@rollup/rollup-darwin-arm64: '-'
rollup>@rollup/rollup-darwin-x64: '-'
rollup>@rollup/rollup-freebsd-arm64: '-'
rollup>@rollup/rollup-freebsd-x64: '-'
rollup>@rollup/rollup-linux-arm-gnueabihf: '-'
rollup>@rollup/rollup-linux-arm-musleabihf: '-'
rollup>@rollup/rollup-linux-arm64-gnu: '-'
rollup>@rollup/rollup-linux-arm64-musl: '-'
rollup>@rollup/rollup-linux-loong64-gnu: '-'
rollup>@rollup/rollup-linux-loong64-musl: '-'
rollup>@rollup/rollup-linux-ppc64-gnu: '-'
rollup>@rollup/rollup-linux-ppc64-musl: '-'
rollup>@rollup/rollup-linux-riscv64-gnu: '-'
rollup>@rollup/rollup-linux-riscv64-musl: '-'
rollup>@rollup/rollup-linux-s390x-gnu: '-'
rollup>@rollup/rollup-linux-x64-musl: '-'
rollup>@rollup/rollup-openbsd-x64: '-'
rollup>@rollup/rollup-openharmony-arm64: '-'
rollup>@rollup/rollup-win32-arm64-msvc: '-'
rollup>@rollup/rollup-win32-ia32-msvc: '-'
rollup>@rollup/rollup-win32-x64-gnu: '-'
rollup>@rollup/rollup-win32-x64-msvc: '-'
+3
View File
@@ -11,6 +11,9 @@
},
{
"path": "./lib/api-zod"
},
{
"path": "./lib/object-storage-web"
}
]
}