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;
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,11 @@
|
||||
"servicePrice": "السعر",
|
||||
"serviceAvailable": "متاح؟",
|
||||
"serviceImageUrl": "رابط الصورة",
|
||||
"serviceImage": "صورة الخدمة",
|
||||
"uploadImage": "رفع صورة",
|
||||
"replaceImage": "استبدال الصورة",
|
||||
"uploading": "جاري الرفع...",
|
||||
"uploadFailed": "فشل رفع الصورة",
|
||||
"users": "المستخدمين",
|
||||
"username": "اسم المستخدم",
|
||||
"email": "البريد الإلكتروني",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ObjectUploader } from "./ObjectUploader";
|
||||
export { useUpload } from "./use-upload";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["esnext", "dom", "dom.iterable"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -12,5 +12,11 @@
|
||||
"devDependencies": {
|
||||
"typescript": "~5.9.2",
|
||||
"prettier": "^3.8.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2250
-223
File diff suppressed because it is too large
Load Diff
+88
-122
@@ -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: '-'
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
},
|
||||
{
|
||||
"path": "./lib/api-zod"
|
||||
},
|
||||
{
|
||||
"path": "./lib/object-storage-web"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user