557 lines
18 KiB
TypeScript
557 lines
18 KiB
TypeScript
import express, { Request, Response } from "express";
|
|
import storage from "../storage";
|
|
import { serializeFile } from "../utils/prismaFileUtils";
|
|
import { CloudFolder } from "@repo/db/types";
|
|
|
|
const router = express.Router();
|
|
|
|
/* ---------- Helpers ---------- */
|
|
function parsePositiveInt(v: unknown, fallback: number) {
|
|
const n = Number(v);
|
|
if (!Number.isFinite(n) || n < 0) return fallback;
|
|
return Math.floor(n);
|
|
}
|
|
|
|
function sendError(
|
|
res: Response,
|
|
status: number,
|
|
message: string,
|
|
details?: any
|
|
) {
|
|
return res.status(status).json({ error: true, message, details });
|
|
}
|
|
|
|
/* ---------- Paginated child FOLDERS for a parent ----------
|
|
GET /items/folders?parentId=&limit=&offset=
|
|
parentId may be "null" or numeric or absent (means root)
|
|
*/
|
|
router.get(
|
|
"/items/folders",
|
|
async (req: Request, res: Response): Promise<any> => {
|
|
const rawParent = req.query.parentId;
|
|
const parentId =
|
|
rawParent === undefined
|
|
? null
|
|
: rawParent === "null"
|
|
? null
|
|
: Number(rawParent);
|
|
|
|
if (parentId !== null && (!Number.isInteger(parentId) || parentId <= 0)) {
|
|
return sendError(res, 400, "Invalid parentId");
|
|
}
|
|
|
|
const limit = parsePositiveInt(req.query.limit, 10); // default 10 folders/page
|
|
const offset = parsePositiveInt(req.query.offset, 0);
|
|
|
|
try {
|
|
// Prefer a storage method that lists folders by parent, otherwise filter
|
|
let data: CloudFolder[] = [];
|
|
if (typeof (storage as any).listFoldersByParent === "function") {
|
|
data = await (storage as any).listFoldersByParent(
|
|
parentId,
|
|
limit,
|
|
offset
|
|
);
|
|
const total =
|
|
(await (storage as any).countFoldersByParent?.(parentId)) ??
|
|
data.length;
|
|
return res.json({ error: false, data, total, limit, offset });
|
|
}
|
|
|
|
// Fallback: use recent and filter (less efficient). Recommend implementing listFoldersByParent in storage.
|
|
const recent = await storage.listRecentFolders(1000, 0);
|
|
const folders = (recent || []).filter(
|
|
(f: any) => (f as any).parentId === parentId
|
|
);
|
|
const paged = folders.slice(offset, offset + limit);
|
|
return res.json({
|
|
error: false,
|
|
data: paged,
|
|
totalCount: folders.length,
|
|
});
|
|
} catch (err) {
|
|
return sendError(res, 500, "Failed to load child folders", err);
|
|
}
|
|
}
|
|
);
|
|
|
|
/* ---------- Paginated files for a folder ----------
|
|
GET /items/files?parentId=&limit=&offset=
|
|
parentId may be "null" or numeric or absent (means root)
|
|
*/
|
|
router.get(
|
|
"/items/files",
|
|
async (req: Request, res: Response): Promise<any> => {
|
|
const rawParent = req.query.parentId;
|
|
const parentId =
|
|
rawParent === undefined
|
|
? null
|
|
: rawParent === "null"
|
|
? null
|
|
: Number(rawParent);
|
|
|
|
if (parentId !== null && (!Number.isInteger(parentId) || parentId <= 0)) {
|
|
return sendError(res, 400, "Invalid parentId");
|
|
}
|
|
|
|
const limit = parsePositiveInt(req.query.limit, 20); // default 20 files/page
|
|
const offset = parsePositiveInt(req.query.offset, 0);
|
|
|
|
try {
|
|
const files = await storage.listFilesInFolder(parentId, limit, offset);
|
|
const totalCount = await storage.countFilesInFolder(parentId);
|
|
const serialized = files.map(serializeFile);
|
|
return res.json({ error: false, data: serialized, totalCount });
|
|
} catch (err) {
|
|
return sendError(res, 500, "Failed to load files for folder", err);
|
|
}
|
|
}
|
|
);
|
|
|
|
/* ---------- Recent folders (global) ----------
|
|
GET /folders/recent?limit=&offset=
|
|
*/
|
|
router.get(
|
|
"/folders/recent",
|
|
async (req: Request, res: Response): Promise<any> => {
|
|
const limit = parsePositiveInt(req.query.limit, 50);
|
|
const offset = parsePositiveInt(req.query.offset, 0);
|
|
try {
|
|
// Always request top-level folders (parentId = null)
|
|
const parentId: number | null = null;
|
|
const folders = await storage.listRecentFolders(limit, offset, parentId);
|
|
const totalCount = await storage.countFoldersByParent(parentId);
|
|
|
|
return res.json({
|
|
error: false,
|
|
data: folders,
|
|
totalCount,
|
|
});
|
|
} catch (err) {
|
|
return sendError(res, 500, "Failed to load recent folders");
|
|
}
|
|
}
|
|
);
|
|
|
|
// ---------- Folder CRUD ----------
|
|
router.get(
|
|
"/folders/:id",
|
|
async (req: Request, res: Response): Promise<any> => {
|
|
const id = Number.parseInt(req.params.id ?? "", 10);
|
|
if (!Number.isInteger(id) || id <= 0)
|
|
return sendError(res, 400, "Invalid folder id");
|
|
|
|
try {
|
|
const folder = await storage.getFolder(id);
|
|
if (!folder) return sendError(res, 404, "Folder not found");
|
|
return res.json({ error: false, data: folder });
|
|
} catch (err) {
|
|
return sendError(res, 500, "Failed to load folder");
|
|
}
|
|
}
|
|
);
|
|
|
|
router.post("/folders", async (req: Request, res: Response): Promise<any> => {
|
|
const { userId, name, parentId } = req.body;
|
|
if (!userId || typeof name !== "string" || !name.trim()) {
|
|
return sendError(res, 400, "Missing or invalid userId/name");
|
|
}
|
|
try {
|
|
const created = await storage.createFolder(
|
|
userId,
|
|
name.trim(),
|
|
parentId ?? null
|
|
);
|
|
return res.status(201).json({ error: false, data: created });
|
|
} catch (err) {
|
|
return sendError(res, 500, "Failed to create folder");
|
|
}
|
|
});
|
|
|
|
router.put(
|
|
"/folders/:id",
|
|
async (req: Request, res: Response): Promise<any> => {
|
|
// coerce possibly-undefined param to string before parsing
|
|
const id = Number.parseInt(req.params.id ?? "", 10);
|
|
if (!Number.isInteger(id) || id <= 0)
|
|
return sendError(res, 400, "Invalid folder id");
|
|
|
|
const updates: any = {};
|
|
if (typeof req.body.name === "string") updates.name = req.body.name.trim();
|
|
if (req.body.parentId !== undefined) updates.parentId = req.body.parentId;
|
|
|
|
try {
|
|
const updated = await storage.updateFolder(id, updates);
|
|
if (!updated)
|
|
return sendError(res, 404, "Folder not found or update failed");
|
|
return res.json({ error: false, data: updated });
|
|
} catch (err) {
|
|
return sendError(res, 500, "Failed to update folder");
|
|
}
|
|
}
|
|
);
|
|
|
|
router.delete(
|
|
"/folders/:id",
|
|
async (req: Request, res: Response): Promise<any> => {
|
|
const id = Number.parseInt(req.params.id ?? "", 10);
|
|
if (!Number.isInteger(id) || id <= 0)
|
|
return sendError(res, 400, "Invalid folder id");
|
|
try {
|
|
const ok = await storage.deleteFolder(id);
|
|
if (!ok) return sendError(res, 404, "Folder not found or delete failed");
|
|
return res.json({ error: false, data: { id } });
|
|
} catch (err) {
|
|
return sendError(res, 500, "Failed to delete folder");
|
|
}
|
|
}
|
|
);
|
|
|
|
/* ---------- Files inside folder (pagination) ----------
|
|
GET /folders/:id/files?limit=&offset=
|
|
id = "null" lists files with folderId = null
|
|
responses serialized
|
|
*/
|
|
router.get(
|
|
"/folders/:id/files",
|
|
async (req: Request, res: Response): Promise<any> => {
|
|
const rawId = req.params.id;
|
|
const folderId = rawId === "null" ? null : Number.parseInt(rawId ?? "", 10);
|
|
if (folderId !== null && (!Number.isInteger(folderId) || folderId <= 0)) {
|
|
return sendError(res, 400, "Invalid folder id");
|
|
}
|
|
|
|
const limit = parsePositiveInt(req.query.limit, 50);
|
|
const offset = parsePositiveInt(req.query.offset, 0);
|
|
|
|
try {
|
|
const files = await storage.listFilesInFolder(folderId, limit, offset);
|
|
const totalCount = await storage.countFilesInFolder(folderId);
|
|
const serialized = files.map(serializeFile);
|
|
return res.json({ error: false, data: serialized, totalCount });
|
|
} catch (err) {
|
|
return sendError(res, 500, "Failed to list files for folder");
|
|
}
|
|
}
|
|
);
|
|
|
|
/* ---------- File CRUD (init, update metadata, delete) ----------
|
|
POST /folders/:id/files { userId, name, mimeType?, expectedSize?, totalChunks? }
|
|
PUT /files/:id { name?, mimeType?, folderId? }
|
|
DELETE /files/:id
|
|
*/
|
|
const MAX_FILE_MB = 20;
|
|
const MAX_FILE_BYTES = MAX_FILE_MB * 1024 * 1024;
|
|
|
|
router.post(
|
|
"/folders/:id/files",
|
|
async (req: Request, res: Response): Promise<any> => {
|
|
const rawId = req.params.id;
|
|
const folderId = rawId === "null" ? null : Number.parseInt(rawId ?? "", 10);
|
|
if (folderId !== null && (!Number.isInteger(folderId) || folderId <= 0)) {
|
|
return sendError(res, 400, "Invalid folder id");
|
|
}
|
|
|
|
const { userId, name, mimeType } = req.body;
|
|
if (!userId || typeof name !== "string" || !name.trim()) {
|
|
return sendError(res, 400, "Missing or invalid userId/name");
|
|
}
|
|
|
|
// coerce size & chunks
|
|
let expectedSize: bigint | null = null;
|
|
if (req.body.expectedSize != null) {
|
|
try {
|
|
// coerce to BigInt safely
|
|
const asNum = Number(req.body.expectedSize);
|
|
if (!Number.isFinite(asNum) || asNum < 0) {
|
|
return sendError(res, 400, "Invalid expectedSize");
|
|
}
|
|
if (asNum > MAX_FILE_BYTES) {
|
|
// Payload Too Large
|
|
return sendError(
|
|
res,
|
|
413,
|
|
`File too large. Max allowed is ${MAX_FILE_MB} MB`
|
|
);
|
|
}
|
|
expectedSize = BigInt(String(req.body.expectedSize));
|
|
} catch {
|
|
return sendError(res, 400, "Invalid expectedSize");
|
|
}
|
|
}
|
|
|
|
let totalChunks: number | null = null;
|
|
if (req.body.totalChunks != null) {
|
|
const tc = Number(req.body.totalChunks);
|
|
if (!Number.isFinite(tc) || tc <= 0)
|
|
return sendError(res, 400, "Invalid totalChunks");
|
|
totalChunks = Math.floor(tc);
|
|
}
|
|
|
|
try {
|
|
const created = await storage.initializeFileUpload(
|
|
userId,
|
|
name.trim(),
|
|
mimeType ?? null,
|
|
expectedSize,
|
|
totalChunks,
|
|
folderId
|
|
);
|
|
return res
|
|
.status(201)
|
|
.json({ error: false, data: serializeFile(created as any) });
|
|
} catch {
|
|
return sendError(res, 500, "Failed to create file");
|
|
}
|
|
}
|
|
);
|
|
|
|
/* ---------- 2. CHUNKS (raw upload) ---------- */
|
|
router.post(
|
|
"/files/:id/chunks",
|
|
// only here: use express.raw so req.body is Buffer
|
|
express.raw({ type: () => true, limit: "100mb" }),
|
|
async (req: Request, res: Response): Promise<any> => {
|
|
const id = Number.parseInt(req.params.id ?? "", 10);
|
|
const seq = Number.parseInt(
|
|
String(req.query.seq ?? req.body.seq ?? ""),
|
|
10
|
|
);
|
|
if (!Number.isInteger(id) || id <= 0)
|
|
return sendError(res, 400, "Invalid file id");
|
|
if (!Number.isInteger(seq) || seq < 0)
|
|
return sendError(res, 400, "Invalid seq");
|
|
|
|
const body = req.body as Buffer;
|
|
|
|
if (!body || !(body instanceof Buffer)) {
|
|
return sendError(res, 400, "Expected raw binary body (Buffer)");
|
|
}
|
|
|
|
// strict size guard: any single chunk must not exceed MAX_FILE_BYTES
|
|
if (body.length > MAX_FILE_BYTES) {
|
|
return sendError(res, 413, `Chunk size exceeds ${MAX_FILE_MB} MB limit`);
|
|
}
|
|
|
|
try {
|
|
await storage.appendFileChunk(id, seq, body);
|
|
return res.json({ error: false, data: { fileId: id, seq } });
|
|
} catch (err: any) {
|
|
return sendError(res, 500, "Failed to add chunk");
|
|
}
|
|
}
|
|
);
|
|
|
|
/* ---------- 3. COMPLETE ---------- */
|
|
router.post(
|
|
"/files/:id/complete",
|
|
async (req: Request, res: Response): Promise<any> => {
|
|
const id = Number.parseInt(req.params.id ?? "", 10);
|
|
if (!Number.isInteger(id) || id <= 0)
|
|
return sendError(res, 400, "Invalid file id");
|
|
|
|
try {
|
|
// Ask storage for the file (includes chunks in your implementation)
|
|
const file = await storage.getFile(id);
|
|
if (!file) return sendError(res, 404, "File not found");
|
|
|
|
// Sum chunks' sizes (storage.getFile returns chunks ordered by seq in your impl)
|
|
const chunks = (file as any).chunks ?? [];
|
|
if (!chunks.length) return sendError(res, 400, "No chunks uploaded");
|
|
|
|
let total = 0;
|
|
for (const c of chunks) {
|
|
// c.data is Bytes / Buffer-like
|
|
total += c.data.length;
|
|
// early bailout
|
|
if (total > MAX_FILE_BYTES) {
|
|
return sendError(
|
|
res,
|
|
413,
|
|
`Assembled file is too large (${Math.round(total / 1024 / 1024)} MB). Max allowed is ${MAX_FILE_MB} MB.`
|
|
);
|
|
}
|
|
}
|
|
|
|
const result = await storage.finalizeFileUpload(id);
|
|
return res.json({ error: false, data: result });
|
|
} catch (err: any) {
|
|
return sendError(res, 500, err?.message || "Failed to complete file");
|
|
}
|
|
}
|
|
);
|
|
|
|
router.put("/files/:id", async (req: Request, res: Response): Promise<any> => {
|
|
const id = Number.parseInt(req.params.id ?? "", 10);
|
|
if (!Number.isInteger(id) || id <= 0)
|
|
return sendError(res, 400, "Invalid file id");
|
|
|
|
const updates: any = {};
|
|
if (typeof req.body.name === "string") updates.name = req.body.name.trim();
|
|
if (typeof req.body.mimeType === "string")
|
|
updates.mimeType = req.body.mimeType;
|
|
if (req.body.folderId !== undefined) updates.folderId = req.body.folderId;
|
|
|
|
try {
|
|
const updated = await storage.updateFile(id, updates);
|
|
if (!updated) return sendError(res, 404, "File not found or update failed");
|
|
return res.json({ error: false, data: serializeFile(updated as any) });
|
|
} catch (err) {
|
|
return sendError(res, 500, "Failed to update file metadata");
|
|
}
|
|
});
|
|
|
|
router.delete(
|
|
"/files/:id",
|
|
async (req: Request, res: Response): Promise<any> => {
|
|
const id = Number.parseInt(req.params.id ?? "", 10);
|
|
if (!Number.isInteger(id) || id <= 0)
|
|
return sendError(res, 400, "Invalid file id");
|
|
|
|
try {
|
|
const ok = await storage.deleteFile(id);
|
|
if (!ok) return sendError(res, 404, "File not found or delete failed");
|
|
return res.json({ error: false, data: { id } });
|
|
} catch (err) {
|
|
return sendError(res, 500, "Failed to delete file");
|
|
}
|
|
}
|
|
);
|
|
|
|
/* GET /files/:id -> return serialized metadata (used by preview modal) */
|
|
router.get("/files/:id", async (req: Request, res: Response): Promise<any> => {
|
|
const id = Number.parseInt(req.params.id ?? "", 10);
|
|
if (!Number.isInteger(id) || id <= 0)
|
|
return sendError(res, 400, "Invalid file id");
|
|
|
|
try {
|
|
const file = await storage.getFile(id);
|
|
if (!file) return sendError(res, 404, "File not found");
|
|
return res.json({ error: false, data: serializeFile(file as any) });
|
|
} catch (err) {
|
|
return sendError(res, 500, "Failed to load file");
|
|
}
|
|
});
|
|
|
|
/* GET /files/:id/content -> stream file with inline disposition for preview */
|
|
router.get(
|
|
"/files/:id/content",
|
|
async (req: Request, res: Response): Promise<any> => {
|
|
const id = Number.parseInt(req.params.id ?? "", 10);
|
|
if (!Number.isInteger(id) || id <= 0)
|
|
return sendError(res, 400, "Invalid file id");
|
|
|
|
try {
|
|
const file = await storage.getFile(id);
|
|
if (!file) return sendError(res, 404, "File not found");
|
|
|
|
const filename = (file.name ?? `file-${(file as any).id}`).replace(
|
|
/["\\]/g,
|
|
""
|
|
);
|
|
|
|
if ((file as any).mimeType)
|
|
res.setHeader("Content-Type", (file as any).mimeType);
|
|
// NOTE: inline instead of attachment so browser can render (images, pdfs)
|
|
res.setHeader(
|
|
"Content-Disposition",
|
|
`inline; filename="${encodeURIComponent(filename)}"`
|
|
);
|
|
|
|
await storage.streamFileTo(res, id);
|
|
|
|
if (!res.writableEnded) res.end();
|
|
} catch (err) {
|
|
if (res.headersSent) return res.end();
|
|
return sendError(res, 500, "Failed to stream file");
|
|
}
|
|
}
|
|
);
|
|
/* GET /files/:id/download */
|
|
router.get(
|
|
"/files/:id/download",
|
|
async (req: Request, res: Response): Promise<any> => {
|
|
const id = Number.parseInt(req.params.id ?? "", 10);
|
|
if (!Number.isInteger(id) || id <= 0)
|
|
return sendError(res, 400, "Invalid file id");
|
|
|
|
try {
|
|
const file = await storage.getFile(id);
|
|
if (!file) return sendError(res, 404, "File not found");
|
|
|
|
const filename = (file.name ?? `file-${(file as any).id}`).replace(
|
|
/["\\]/g,
|
|
""
|
|
);
|
|
if ((file as any).mimeType)
|
|
res.setHeader("Content-Type", (file as any).mimeType);
|
|
res.setHeader(
|
|
"Content-Disposition",
|
|
`attachment; filename="${encodeURIComponent(filename)}"`
|
|
);
|
|
|
|
await storage.streamFileTo(res, id);
|
|
|
|
if (!res.writableEnded) res.end();
|
|
} catch (err) {
|
|
if (res.headersSent) return res.end();
|
|
return sendError(res, 500, "Failed to stream file");
|
|
}
|
|
}
|
|
);
|
|
|
|
/* ---------- Search endpoints (separate) ----------
|
|
GET /search/folders?q=&limit=&offset=
|
|
GET /search/files?q=&type=&limit=&offset=
|
|
*/
|
|
router.get(
|
|
"/search/folders",
|
|
async (req: Request, res: Response): Promise<any> => {
|
|
const q = String(req.query.q ?? "").trim();
|
|
const limit = parsePositiveInt(req.query.limit, 20);
|
|
const offset = parsePositiveInt(req.query.offset, 0);
|
|
if (!q) return sendError(res, 400, "Missing search query parameter 'q'");
|
|
|
|
try {
|
|
const parentId = null;
|
|
|
|
const { data, total } = await storage.searchFolders(
|
|
q,
|
|
limit,
|
|
offset,
|
|
parentId
|
|
);
|
|
return res.json({ error: false, data, totalCount: total });
|
|
} catch (err) {
|
|
return sendError(res, 500, "Folder search failed");
|
|
}
|
|
}
|
|
);
|
|
|
|
router.get(
|
|
"/search/files",
|
|
async (req: Request, res: Response): Promise<any> => {
|
|
const q = String(req.query.q ?? "").trim();
|
|
const type =
|
|
typeof req.query.type === "string" ? req.query.type.trim() : undefined;
|
|
const limit = parsePositiveInt(req.query.limit, 20);
|
|
const offset = parsePositiveInt(req.query.offset, 0);
|
|
if (!q && !type)
|
|
return sendError(
|
|
res,
|
|
400,
|
|
"Provide at least one of 'q' or 'type' to search files"
|
|
);
|
|
|
|
try {
|
|
const { data, total } = await storage.searchFiles(q, type, limit, offset);
|
|
const serialized = data.map(serializeFile);
|
|
return res.json({ error: false, data: serialized, totalCount: total });
|
|
} catch (err) {
|
|
return sendError(res, 500, "File search failed");
|
|
}
|
|
}
|
|
);
|
|
|
|
export default router;
|