Add ability to upload and manage service images
Integrates Uppy.js for file uploads, adds new API endpoints for requesting upload URLs, and updates UI components to support image uploads.
This commit is contained in:
@@ -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