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:
riyadhafraa
2026-04-20 10:55:11 +00:00
parent 40100125da
commit 8df5e76d29
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"
}
]
}