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"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@google-cloud/storage": "^7.19.0",
|
||||||
"@workspace/api-zod": "workspace:*",
|
"@workspace/api-zod": "workspace:*",
|
||||||
"@workspace/db": "workspace:*",
|
"@workspace/db": "workspace:*",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"express": "^5",
|
"express": "^5",
|
||||||
"express-session": "^1.19.0",
|
"express-session": "^1.19.0",
|
||||||
|
"google-auth-library": "^10.6.2",
|
||||||
"pino": "^9",
|
"pino": "^9",
|
||||||
"pino-http": "^10",
|
"pino-http": "^10",
|
||||||
"socket.io": "^4.8.3"
|
"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 notificationsRouter from "./notifications";
|
||||||
import usersRouter from "./users";
|
import usersRouter from "./users";
|
||||||
import statsRouter from "./stats";
|
import statsRouter from "./stats";
|
||||||
|
import storageRouter from "./storage";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
@@ -18,5 +19,6 @@ router.use(conversationsRouter);
|
|||||||
router.use(notificationsRouter);
|
router.use(notificationsRouter);
|
||||||
router.use(usersRouter);
|
router.use(usersRouter);
|
||||||
router.use(statsRouter);
|
router.use(statsRouter);
|
||||||
|
router.use(storageRouter);
|
||||||
|
|
||||||
export default router;
|
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:",
|
"vite": "catalog:",
|
||||||
"wouter": "^3.3.5",
|
"wouter": "^3.3.5",
|
||||||
"zod": "catalog:"
|
"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": "السعر",
|
"servicePrice": "السعر",
|
||||||
"serviceAvailable": "متاح؟",
|
"serviceAvailable": "متاح؟",
|
||||||
"serviceImageUrl": "رابط الصورة",
|
"serviceImageUrl": "رابط الصورة",
|
||||||
|
"serviceImage": "صورة الخدمة",
|
||||||
|
"uploadImage": "رفع صورة",
|
||||||
|
"replaceImage": "استبدال الصورة",
|
||||||
|
"uploading": "جاري الرفع...",
|
||||||
|
"uploadFailed": "فشل رفع الصورة",
|
||||||
"users": "المستخدمين",
|
"users": "المستخدمين",
|
||||||
"username": "اسم المستخدم",
|
"username": "اسم المستخدم",
|
||||||
"email": "البريد الإلكتروني",
|
"email": "البريد الإلكتروني",
|
||||||
|
|||||||
@@ -80,6 +80,11 @@
|
|||||||
"servicePrice": "Price",
|
"servicePrice": "Price",
|
||||||
"serviceAvailable": "Available?",
|
"serviceAvailable": "Available?",
|
||||||
"serviceImageUrl": "Image URL",
|
"serviceImageUrl": "Image URL",
|
||||||
|
"serviceImage": "Service Image",
|
||||||
|
"uploadImage": "Upload Image",
|
||||||
|
"replaceImage": "Replace Image",
|
||||||
|
"uploading": "Uploading...",
|
||||||
|
"uploadFailed": "Image upload failed",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import {
|
|||||||
} from "@workspace/api-client-react";
|
} from "@workspace/api-client-react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -221,7 +222,6 @@ export default function AdminPage() {
|
|||||||
{ field: "descriptionAr", label: t("admin.serviceDescriptionAr") },
|
{ field: "descriptionAr", label: t("admin.serviceDescriptionAr") },
|
||||||
{ field: "descriptionEn", label: t("admin.serviceDescriptionEn") },
|
{ field: "descriptionEn", label: t("admin.serviceDescriptionEn") },
|
||||||
{ field: "price", label: t("admin.servicePrice") },
|
{ field: "price", label: t("admin.servicePrice") },
|
||||||
{ field: "imageUrl", label: t("admin.serviceImageUrl") },
|
|
||||||
] as { field: keyof ServiceForm; label: string }[]
|
] as { field: keyof ServiceForm; label: string }[]
|
||||||
).map(({ field, label }) => (
|
).map(({ field, label }) => (
|
||||||
<div key={field} className="space-y-1">
|
<div key={field} className="space-y-1">
|
||||||
@@ -230,20 +230,22 @@ export default function AdminPage() {
|
|||||||
value={String(editingService.form[field])}
|
value={String(editingService.form[field])}
|
||||||
onChange={(e) => setEditingService({ ...editingService, form: { ...editingService.form, [field]: e.target.value } })}
|
onChange={(e) => setEditingService({ ...editingService, form: { ...editingService.form, [field]: e.target.value } })}
|
||||||
className="bg-white/70 border-slate-200"
|
className="bg-white/70 border-slate-200"
|
||||||
placeholder={field === "imageUrl" ? "https://..." : undefined}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{editingService.form.imageUrl && (
|
|
||||||
<div className="rounded-xl overflow-hidden border border-slate-200 bg-slate-50">
|
<div className="space-y-2">
|
||||||
<img
|
<Label>{t("admin.serviceImage")}</Label>
|
||||||
src={editingService.form.imageUrl}
|
<ServiceImageUploader
|
||||||
alt=""
|
value={editingService.form.imageUrl}
|
||||||
className="w-full h-32 object-cover"
|
onChange={(url) =>
|
||||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
|
setEditingService({
|
||||||
/>
|
...editingService,
|
||||||
</div>
|
form: { ...editingService.form, imageUrl: url },
|
||||||
)}
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>{t("admin.serviceAvailable")}</Label>
|
<Label>{t("admin.serviceAvailable")}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -491,3 +493,71 @@ export default function AdminPage() {
|
|||||||
</div>
|
</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": [
|
"references": [
|
||||||
{
|
{
|
||||||
"path": "../../lib/api-client-react"
|
"path": "../../lib/api-client-react"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../../lib/object-storage-web"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,6 +257,21 @@ export interface Notification {
|
|||||||
createdAt: string;
|
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 {
|
export interface HomeStats {
|
||||||
totalApps: number;
|
totalApps: number;
|
||||||
totalServices: number;
|
totalServices: number;
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import type {
|
|||||||
MessageWithSender,
|
MessageWithSender,
|
||||||
Notification,
|
Notification,
|
||||||
RegisterBody,
|
RegisterBody,
|
||||||
|
RequestUploadUrlBody,
|
||||||
|
RequestUploadUrlResponse,
|
||||||
SendMessageBody,
|
SendMessageBody,
|
||||||
Service,
|
Service,
|
||||||
ServiceCategory,
|
ServiceCategory,
|
||||||
@@ -924,6 +926,92 @@ export const useDeleteApp = <
|
|||||||
return useMutation(getDeleteAppMutationOptions(options));
|
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
|
* @summary List all services
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ tags:
|
|||||||
description: User management
|
description: User management
|
||||||
- name: stats
|
- name: stats
|
||||||
description: Dashboard stats
|
description: Dashboard stats
|
||||||
|
- name: storage
|
||||||
|
description: Object storage upload and serving endpoints
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
/healthz:
|
/healthz:
|
||||||
@@ -234,6 +236,32 @@ paths:
|
|||||||
"204":
|
"204":
|
||||||
description: Deleted
|
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
|
||||||
/services:
|
/services:
|
||||||
get:
|
get:
|
||||||
@@ -1112,6 +1140,32 @@ components:
|
|||||||
- isRead
|
- isRead
|
||||||
- createdAt
|
- 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:
|
HomeStats:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -197,6 +197,28 @@ export const DeleteAppParams = zod.object({
|
|||||||
id: zod.coerce.number(),
|
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
|
* @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": {
|
"devDependencies": {
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"prettier": "^3.8.1"
|
"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:
|
packages:
|
||||||
- artifacts/*
|
- artifacts/*
|
||||||
- lib/*
|
- lib/*
|
||||||
- lib/integrations/*
|
- lib/integrations/*
|
||||||
- scripts
|
- scripts
|
||||||
|
|
||||||
|
autoInstallPeers: false
|
||||||
|
|
||||||
catalog:
|
catalog:
|
||||||
'@replit/vite-plugin-cartographer': ^0.5.1
|
'@replit/vite-plugin-cartographer': ^0.5.1
|
||||||
'@replit/vite-plugin-dev-banner': ^0.1.1
|
'@replit/vite-plugin-dev-banner': ^0.1.1
|
||||||
@@ -55,9 +21,7 @@ catalog:
|
|||||||
drizzle-orm: ^0.45.2
|
drizzle-orm: ^0.45.2
|
||||||
framer-motion: ^12.23.24
|
framer-motion: ^12.23.24
|
||||||
lucide-react: ^0.545.0
|
lucide-react: ^0.545.0
|
||||||
# Must be this exact version because expo requires it
|
|
||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
# Must be this exact version because expo requires it
|
|
||||||
react-dom: 19.1.0
|
react-dom: 19.1.0
|
||||||
tailwind-merge: ^3.3.1
|
tailwind-merge: ^3.3.1
|
||||||
tailwindcss: ^4.1.14
|
tailwindcss: ^4.1.14
|
||||||
@@ -65,7 +29,11 @@ catalog:
|
|||||||
vite: ^7.3.2
|
vite: ^7.3.2
|
||||||
zod: ^3.25.76
|
zod: ^3.25.76
|
||||||
|
|
||||||
autoInstallPeers: false
|
minimumReleaseAge: 1440
|
||||||
|
|
||||||
|
minimumReleaseAgeExclude:
|
||||||
|
- '@replit/*'
|
||||||
|
- stripe-replit-sync
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- '@swc/core'
|
- '@swc/core'
|
||||||
@@ -74,86 +42,84 @@ onlyBuiltDependencies:
|
|||||||
- unrs-resolver
|
- unrs-resolver
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
# replit uses linux-x64 only, we can exclude all other platforms
|
'@esbuild-kit/esm-loader': npm:tsx@^4.21.0
|
||||||
"esbuild>@esbuild/darwin-arm64": "-"
|
'@expo/ngrok-bin>@expo/ngrok-bin-darwin-arm64': '-'
|
||||||
"esbuild>@esbuild/darwin-x64": "-"
|
'@expo/ngrok-bin>@expo/ngrok-bin-darwin-x64': '-'
|
||||||
"esbuild>@esbuild/freebsd-arm64": "-"
|
'@expo/ngrok-bin>@expo/ngrok-bin-freebsd-ia32': '-'
|
||||||
"esbuild>@esbuild/freebsd-x64": "-"
|
'@expo/ngrok-bin>@expo/ngrok-bin-freebsd-x64': '-'
|
||||||
"esbuild>@esbuild/linux-arm": "-"
|
'@expo/ngrok-bin>@expo/ngrok-bin-linux-arm': '-'
|
||||||
"esbuild>@esbuild/linux-arm64": "-"
|
'@expo/ngrok-bin>@expo/ngrok-bin-linux-arm64': '-'
|
||||||
"esbuild>@esbuild/linux-ia32": "-"
|
'@expo/ngrok-bin>@expo/ngrok-bin-linux-ia32': '-'
|
||||||
"esbuild>@esbuild/linux-loong64": "-"
|
'@expo/ngrok-bin>@expo/ngrok-bin-sunos-x64': '-'
|
||||||
"esbuild>@esbuild/linux-mips64el": "-"
|
'@expo/ngrok-bin>@expo/ngrok-bin-win32-ia32': '-'
|
||||||
"esbuild>@esbuild/linux-ppc64": "-"
|
'@expo/ngrok-bin>@expo/ngrok-bin-win32-x64': '-'
|
||||||
"esbuild>@esbuild/linux-riscv64": "-"
|
'@tailwindcss/oxide>@tailwindcss/oxide-android-arm64': '-'
|
||||||
"esbuild>@esbuild/linux-s390x": "-"
|
'@tailwindcss/oxide>@tailwindcss/oxide-darwin-arm64': '-'
|
||||||
"esbuild>@esbuild/netbsd-arm64": "-"
|
'@tailwindcss/oxide>@tailwindcss/oxide-darwin-x64': '-'
|
||||||
"esbuild>@esbuild/netbsd-x64": "-"
|
'@tailwindcss/oxide>@tailwindcss/oxide-freebsd-x64': '-'
|
||||||
"esbuild>@esbuild/openbsd-arm64": "-"
|
'@tailwindcss/oxide>@tailwindcss/oxide-linux-arm-gnueabihf': '-'
|
||||||
"esbuild>@esbuild/openbsd-x64": "-"
|
'@tailwindcss/oxide>@tailwindcss/oxide-linux-arm64-gnu': '-'
|
||||||
"esbuild>@esbuild/sunos-x64": "-"
|
'@tailwindcss/oxide>@tailwindcss/oxide-linux-arm64-musl': '-'
|
||||||
"esbuild>@esbuild/win32-arm64": "-"
|
'@tailwindcss/oxide>@tailwindcss/oxide-linux-x64-musl': '-'
|
||||||
"esbuild>@esbuild/win32-ia32": "-"
|
'@tailwindcss/oxide>@tailwindcss/oxide-win32-arm64-msvc': '-'
|
||||||
"esbuild>@esbuild/win32-x64": "-"
|
'@tailwindcss/oxide>@tailwindcss/oxide-win32-x64-msvc': '-'
|
||||||
"esbuild>@esbuild/aix-ppc64": '-'
|
esbuild: 0.27.3
|
||||||
"esbuild>@esbuild/android-arm": '-'
|
esbuild>@esbuild/aix-ppc64: '-'
|
||||||
"esbuild>@esbuild/android-arm64": '-'
|
esbuild>@esbuild/android-arm: '-'
|
||||||
"esbuild>@esbuild/android-x64": '-'
|
esbuild>@esbuild/android-arm64: '-'
|
||||||
"esbuild>@esbuild/openharmony-arm64": '-'
|
esbuild>@esbuild/android-x64: '-'
|
||||||
"lightningcss>lightningcss-android-arm64": "-"
|
esbuild>@esbuild/darwin-arm64: '-'
|
||||||
"lightningcss>lightningcss-darwin-arm64": "-"
|
esbuild>@esbuild/darwin-x64: '-'
|
||||||
"lightningcss>lightningcss-darwin-x64": "-"
|
esbuild>@esbuild/freebsd-arm64: '-'
|
||||||
"lightningcss>lightningcss-freebsd-x64": "-"
|
esbuild>@esbuild/freebsd-x64: '-'
|
||||||
"lightningcss>lightningcss-linux-arm-gnueabihf": "-"
|
esbuild>@esbuild/linux-arm: '-'
|
||||||
"lightningcss>lightningcss-linux-arm64-gnu": "-"
|
esbuild>@esbuild/linux-arm64: '-'
|
||||||
"lightningcss>lightningcss-linux-arm64-musl": "-"
|
esbuild>@esbuild/linux-ia32: '-'
|
||||||
"lightningcss>lightningcss-linux-x64-musl": "-"
|
esbuild>@esbuild/linux-loong64: '-'
|
||||||
"lightningcss>lightningcss-win32-arm64-msvc": "-"
|
esbuild>@esbuild/linux-mips64el: '-'
|
||||||
"lightningcss>lightningcss-win32-x64-msvc": "-"
|
esbuild>@esbuild/linux-ppc64: '-'
|
||||||
"@tailwindcss/oxide>@tailwindcss/oxide-android-arm64": "-"
|
esbuild>@esbuild/linux-riscv64: '-'
|
||||||
"@tailwindcss/oxide>@tailwindcss/oxide-darwin-arm64": "-"
|
esbuild>@esbuild/linux-s390x: '-'
|
||||||
"@tailwindcss/oxide>@tailwindcss/oxide-darwin-x64": "-"
|
esbuild>@esbuild/netbsd-arm64: '-'
|
||||||
"@tailwindcss/oxide>@tailwindcss/oxide-freebsd-x64": "-"
|
esbuild>@esbuild/netbsd-x64: '-'
|
||||||
"@tailwindcss/oxide>@tailwindcss/oxide-linux-arm-gnueabihf": "-"
|
esbuild>@esbuild/openbsd-arm64: '-'
|
||||||
"@tailwindcss/oxide>@tailwindcss/oxide-linux-arm64-gnu": "-"
|
esbuild>@esbuild/openbsd-x64: '-'
|
||||||
"@tailwindcss/oxide>@tailwindcss/oxide-linux-arm64-musl": "-"
|
esbuild>@esbuild/openharmony-arm64: '-'
|
||||||
"@tailwindcss/oxide>@tailwindcss/oxide-win32-arm64-msvc": "-"
|
esbuild>@esbuild/sunos-x64: '-'
|
||||||
"@tailwindcss/oxide>@tailwindcss/oxide-win32-x64-msvc": "-"
|
esbuild>@esbuild/win32-arm64: '-'
|
||||||
"@tailwindcss/oxide>@tailwindcss/oxide-linux-x64-musl": "-"
|
esbuild>@esbuild/win32-ia32: '-'
|
||||||
"rollup>@rollup/rollup-android-arm-eabi": "-"
|
esbuild>@esbuild/win32-x64: '-'
|
||||||
"rollup>@rollup/rollup-android-arm64": "-"
|
lightningcss>lightningcss-android-arm64: '-'
|
||||||
"rollup>@rollup/rollup-darwin-arm64": "-"
|
lightningcss>lightningcss-darwin-arm64: '-'
|
||||||
"rollup>@rollup/rollup-darwin-x64": "-"
|
lightningcss>lightningcss-darwin-x64: '-'
|
||||||
"rollup>@rollup/rollup-freebsd-arm64": "-"
|
lightningcss>lightningcss-freebsd-x64: '-'
|
||||||
"rollup>@rollup/rollup-freebsd-x64": "-"
|
lightningcss>lightningcss-linux-arm-gnueabihf: '-'
|
||||||
"rollup>@rollup/rollup-linux-arm-gnueabihf": "-"
|
lightningcss>lightningcss-linux-arm64-gnu: '-'
|
||||||
"rollup>@rollup/rollup-linux-arm-musleabihf": "-"
|
lightningcss>lightningcss-linux-arm64-musl: '-'
|
||||||
"rollup>@rollup/rollup-linux-arm64-gnu": "-"
|
lightningcss>lightningcss-linux-x64-musl: '-'
|
||||||
"rollup>@rollup/rollup-linux-arm64-musl": "-"
|
lightningcss>lightningcss-win32-arm64-msvc: '-'
|
||||||
"rollup>@rollup/rollup-linux-loong64-gnu": "-"
|
lightningcss>lightningcss-win32-x64-msvc: '-'
|
||||||
"rollup>@rollup/rollup-linux-loong64-musl": "-"
|
rollup>@rollup/rollup-android-arm-eabi: '-'
|
||||||
"rollup>@rollup/rollup-linux-ppc64-gnu": "-"
|
rollup>@rollup/rollup-android-arm64: '-'
|
||||||
"rollup>@rollup/rollup-linux-ppc64-musl": "-"
|
rollup>@rollup/rollup-darwin-arm64: '-'
|
||||||
"rollup>@rollup/rollup-linux-riscv64-gnu": "-"
|
rollup>@rollup/rollup-darwin-x64: '-'
|
||||||
"rollup>@rollup/rollup-linux-riscv64-musl": "-"
|
rollup>@rollup/rollup-freebsd-arm64: '-'
|
||||||
"rollup>@rollup/rollup-linux-s390x-gnu": "-"
|
rollup>@rollup/rollup-freebsd-x64: '-'
|
||||||
"rollup>@rollup/rollup-linux-x64-musl": "-"
|
rollup>@rollup/rollup-linux-arm-gnueabihf: '-'
|
||||||
"rollup>@rollup/rollup-openbsd-x64": "-"
|
rollup>@rollup/rollup-linux-arm-musleabihf: '-'
|
||||||
"rollup>@rollup/rollup-openharmony-arm64": "-"
|
rollup>@rollup/rollup-linux-arm64-gnu: '-'
|
||||||
"rollup>@rollup/rollup-win32-arm64-msvc": "-"
|
rollup>@rollup/rollup-linux-arm64-musl: '-'
|
||||||
"rollup>@rollup/rollup-win32-ia32-msvc": "-"
|
rollup>@rollup/rollup-linux-loong64-gnu: '-'
|
||||||
"rollup>@rollup/rollup-win32-x64-gnu": "-"
|
rollup>@rollup/rollup-linux-loong64-musl: '-'
|
||||||
"rollup>@rollup/rollup-win32-x64-msvc": "-"
|
rollup>@rollup/rollup-linux-ppc64-gnu: '-'
|
||||||
"@expo/ngrok-bin>@expo/ngrok-bin-darwin-arm64": "-"
|
rollup>@rollup/rollup-linux-ppc64-musl: '-'
|
||||||
"@expo/ngrok-bin>@expo/ngrok-bin-darwin-x64": "-"
|
rollup>@rollup/rollup-linux-riscv64-gnu: '-'
|
||||||
"@expo/ngrok-bin>@expo/ngrok-bin-freebsd-ia32": "-"
|
rollup>@rollup/rollup-linux-riscv64-musl: '-'
|
||||||
"@expo/ngrok-bin>@expo/ngrok-bin-freebsd-x64": "-"
|
rollup>@rollup/rollup-linux-s390x-gnu: '-'
|
||||||
"@expo/ngrok-bin>@expo/ngrok-bin-linux-arm64": "-"
|
rollup>@rollup/rollup-linux-x64-musl: '-'
|
||||||
"@expo/ngrok-bin>@expo/ngrok-bin-linux-arm": "-"
|
rollup>@rollup/rollup-openbsd-x64: '-'
|
||||||
"@expo/ngrok-bin>@expo/ngrok-bin-linux-ia32": "-"
|
rollup>@rollup/rollup-openharmony-arm64: '-'
|
||||||
"@expo/ngrok-bin>@expo/ngrok-bin-sunos-x64": "-"
|
rollup>@rollup/rollup-win32-arm64-msvc: '-'
|
||||||
"@expo/ngrok-bin>@expo/ngrok-bin-win32-ia32": "-"
|
rollup>@rollup/rollup-win32-ia32-msvc: '-'
|
||||||
"@expo/ngrok-bin>@expo/ngrok-bin-win32-x64": "-"
|
rollup>@rollup/rollup-win32-x64-gnu: '-'
|
||||||
# drizzle-kit uses esbuild internally on an older version that's vulnerable, this overrides it
|
rollup>@rollup/rollup-win32-x64-msvc: '-'
|
||||||
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0"
|
|
||||||
esbuild: "0.27.3"
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./lib/api-zod"
|
"path": "./lib/api-zod"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./lib/object-storage-web"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user