xtablo-source/apps/api/src/routers/tablo_data.ts
2026-03-08 21:28:44 +01:00

497 lines
16 KiB
TypeScript

import {
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import type { TabloFolder, TabloFoldersMetadata } from "@xtablo/shared-types";
import { Hono } from "hono";
import { createFactory } from "hono/factory";
import { checkTabloAdmin, checkTabloMember, getTabloFileNames } from "../helpers/helpers.js";
import { MiddlewareManager } from "../middlewares/middleware.js";
import type { AuthEnv } from "../types/app.types.js";
const factory = createFactory<AuthEnv>();
// Metadata file name for folders
const FOLDERS_METADATA_FILE = ".tablo-folders.json";
const CACHE_TTL_MS = 15_000;
type CacheEntry<T> = {
value: T;
expiresAt: number;
};
const fileNamesCache = new Map<string, CacheEntry<string[]>>();
const foldersCache = new Map<string, CacheEntry<TabloFoldersMetadata>>();
export const clearTabloDataCachesForTests = () => {
fileNamesCache.clear();
foldersCache.clear();
};
// Helper to generate unique folder IDs
const generateFolderId = () => `folder-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const getCachedValue = <T>(entry: CacheEntry<T> | undefined): T | null => {
if (!entry) return null;
if (Date.now() >= entry.expiresAt) return null;
return entry.value;
};
const setCacheValue = <T>(map: Map<string, CacheEntry<T>>, key: string, value: T) => {
map.set(key, {
value,
expiresAt: Date.now() + CACHE_TTL_MS,
});
};
const getCachedTabloFileNames = async (s3_client: S3Client, tabloId: string): Promise<string[]> => {
const cached = getCachedValue(fileNamesCache.get(tabloId));
if (cached) {
return cached;
}
const fileNames = (await getTabloFileNames(s3_client, tabloId)) || [];
setCacheValue(fileNamesCache, tabloId, fileNames);
return fileNames;
};
// ============================================
// FILE ENDPOINTS
// ============================================
const getTabloFilenames = factory.createHandlers(checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId");
const s3_client = c.get("s3_client");
try {
const fileNames = await getCachedTabloFileNames(s3_client, tabloId);
return c.json({ fileNames });
} catch (error) {
console.error("Error fetching tablo files:", error);
return c.json({ error: "Failed to fetch tablo files" }, 500);
}
});
// Returns file names for all tablos the authenticated user has access to, in one request
const getAllTablosFilenames = factory.createHandlers(async (c) => {
const supabase = c.get("supabase");
const user = c.get("user");
const s3_client = c.get("s3_client");
try {
const { data: tabloAccess, error } = await supabase
.from("tablo_access")
.select("tablo_id")
.eq("user_id", user.id)
.eq("is_active", true);
if (error) {
return c.json({ error: "Failed to fetch tablos" }, 500);
}
const tabloIds = (tabloAccess ?? []).map((row: { tablo_id: string }) => row.tablo_id);
const results = await Promise.all(
tabloIds.map(async (tabloId: string) => {
const fileNames = await getCachedTabloFileNames(s3_client, tabloId);
return { tabloId, fileNames };
})
);
return c.json({ tablos: results });
} catch (error) {
console.error("Error fetching all tablo files:", error);
return c.json({ error: "Failed to fetch all tablo files" }, 500);
}
});
const getTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId");
// Get the file path - supports both wildcard (*) and named parameter (:fileName)
const filePath = c.req.param("path") || c.req.param("fileName");
if (!filePath) {
return c.json({ error: "File path is required" }, 400);
}
const s3_client = c.get("s3_client");
try {
const response = await s3_client.send(
new GetObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${filePath}`,
})
);
if (!response.Body) {
return c.json({ error: "File not found" }, 404);
}
const content = await response.Body.transformToString();
return c.json({
fileName: filePath,
content,
contentType: response.ContentType,
lastModified: response.LastModified,
});
} catch (error) {
console.error("Error fetching file:", error);
return c.json({ error: "Failed to fetch file" }, 500);
}
});
const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId");
const user = c.get("user");
// Get the file path - supports both wildcard (*) and named parameter (:fileName)
const filePath = c.req.param("path") || c.req.param("fileName");
if (!filePath) {
return c.json({ error: "File path is required" }, 400);
}
const s3_client = c.get("s3_client");
try {
const body = await c.req.json();
const { content, contentType = "text/plain" } = body;
if (!content) {
return c.json({ error: "Content is required" }, 400);
}
await s3_client.send(
new PutObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${filePath}`,
Body: content,
ContentType: contentType,
Metadata: {
"uploaded-by": user.id,
},
})
);
fileNamesCache.delete(tabloId);
return c.json({
message: "File uploaded successfully",
fileName: filePath,
tabloId,
});
} catch (error) {
console.error("Error uploading file:", error);
return c.json({ error: "Failed to upload file" }, 500);
}
});
const deleteTabloFile = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId");
const user = c.get("user");
const supabase = c.get("supabase");
// Get the file path - supports both wildcard (*) and named parameter (:fileName)
const filePath = c.req.param("path") || c.req.param("fileName");
if (!filePath) {
return c.json({ error: "File path is required" }, 400);
}
const s3_client = c.get("s3_client");
try {
const { data: access, error: accessError } = await supabase
.from("tablo_access")
.select("is_admin")
.eq("tablo_id", tabloId)
.eq("user_id", user.id)
.eq("is_active", true)
.maybeSingle();
if (accessError) {
return c.json({ error: "Failed to verify access rights" }, 500);
}
if (!access) {
return c.json({ error: "You are not a member of this tablo" }, 403);
}
if (!access.is_admin) {
try {
const headResponse = await s3_client.send(
new HeadObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${filePath}`,
})
);
const uploadedBy =
headResponse.Metadata?.["uploaded-by"] ?? headResponse.Metadata?.uploaded_by ?? null;
if (uploadedBy !== user.id) {
return c.json({ error: "You can only delete files you uploaded" }, 403);
}
} catch (error) {
const statusCode = (error as { $metadata?: { httpStatusCode?: number } })?.$metadata
?.httpStatusCode;
if (statusCode === 404) {
return c.json({ error: "You can only delete files you uploaded" }, 403);
}
console.error("Error checking file ownership:", error);
return c.json({ error: "Failed to verify file ownership" }, 500);
}
}
await s3_client.send(
new DeleteObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${filePath}`,
})
);
fileNamesCache.delete(tabloId);
return c.json({
message: "File deleted successfully",
fileName: filePath,
tabloId,
});
} catch (error) {
console.error("Error deleting file:", error);
return c.json({ error: "Failed to delete file" }, 500);
}
});
// ============================================
// FOLDER ENDPOINTS
// ============================================
// Helper to get or create folder metadata
const getFolderMetadata = async (
s3_client: S3Client,
tabloId: string
): Promise<TabloFoldersMetadata> => {
const cached = getCachedValue(foldersCache.get(tabloId));
if (cached) {
return cached;
}
try {
const response = await s3_client.send(
new GetObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${FOLDERS_METADATA_FILE}`,
})
);
if (response.Body) {
const content = await response.Body.transformToString();
const metadata = JSON.parse(content) as TabloFoldersMetadata;
setCacheValue(foldersCache, tabloId, metadata);
return metadata;
}
} catch {
// File doesn't exist, return default
}
const emptyMetadata = { folders: [], version: 1 };
setCacheValue(foldersCache, tabloId, emptyMetadata);
return emptyMetadata;
};
// Helper to save folder metadata
const saveFolderMetadata = async (
s3_client: S3Client,
tabloId: string,
metadata: TabloFoldersMetadata
) => {
await s3_client.send(
new PutObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${FOLDERS_METADATA_FILE}`,
Body: JSON.stringify(metadata, null, 2),
ContentType: "application/json",
})
);
setCacheValue(foldersCache, tabloId, metadata);
};
// GET /tablo-data/:tabloId/folders - Get all folders for a tablo
const getTabloFolders = factory.createHandlers(checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId");
const s3_client = c.get("s3_client");
try {
const metadata = await getFolderMetadata(s3_client, tabloId);
return c.json(metadata);
} catch (error) {
console.error("Error fetching folders:", error);
return c.json({ error: "Failed to fetch folders" }, 500);
}
});
// POST /tablo-data/:tabloId/folders - Create a new folder (admin only)
const createTabloFolder = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
const tabloId = c.req.param("tabloId");
const s3_client = c.get("s3_client");
const user = c.get("user");
try {
const body = await c.req.json();
const { name, description } = body;
if (!name || typeof name !== "string" || !name.trim()) {
return c.json({ error: "Folder name is required" }, 400);
}
const metadata = await getFolderMetadata(s3_client, tabloId);
// Check for duplicate folder names
if (metadata.folders.some((f) => f.name.toLowerCase() === name.trim().toLowerCase())) {
return c.json({ error: "A folder with this name already exists" }, 400);
}
const newFolder: TabloFolder = {
id: generateFolderId(),
name: name.trim(),
description: description?.trim() || undefined,
createdAt: new Date().toISOString(),
createdBy: user.id,
};
metadata.folders.push(newFolder);
metadata.version += 1;
await saveFolderMetadata(s3_client, tabloId, metadata);
return c.json({
message: "Folder created successfully",
folder: newFolder,
});
} catch (error) {
console.error("Error creating folder:", error);
return c.json({ error: "Failed to create folder" }, 500);
}
});
// PUT /tablo-data/:tabloId/folders/:folderId - Update a folder (admin only)
const updateTabloFolder = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
const tabloId = c.req.param("tabloId");
const folderId = c.req.param("folderId");
const s3_client = c.get("s3_client");
try {
const body = await c.req.json();
const { name, description } = body;
const metadata = await getFolderMetadata(s3_client, tabloId);
const folderIndex = metadata.folders.findIndex((f) => f.id === folderId);
if (folderIndex === -1) {
return c.json({ error: "Folder not found" }, 404);
}
// Check for duplicate folder names (excluding current folder)
if (
name &&
metadata.folders.some(
(f, idx) => idx !== folderIndex && f.name.toLowerCase() === name.trim().toLowerCase()
)
) {
return c.json({ error: "A folder with this name already exists" }, 400);
}
// Update folder
if (name) {
metadata.folders[folderIndex].name = name.trim();
}
if (description !== undefined) {
metadata.folders[folderIndex].description = description?.trim() || undefined;
}
metadata.version += 1;
await saveFolderMetadata(s3_client, tabloId, metadata);
return c.json({
message: "Folder updated successfully",
folder: metadata.folders[folderIndex],
});
} catch (error) {
console.error("Error updating folder:", error);
return c.json({ error: "Failed to update folder" }, 500);
}
});
// DELETE /tablo-data/:tabloId/folders/:folderId - Delete a folder (admin only)
const deleteTabloFolder = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
const tabloId = c.req.param("tabloId");
const folderId = c.req.param("folderId");
const s3_client = c.get("s3_client");
try {
const metadata = await getFolderMetadata(s3_client, tabloId);
const folderIndex = metadata.folders.findIndex((f) => f.id === folderId);
if (folderIndex === -1) {
return c.json({ error: "Folder not found" }, 404);
}
const deletedFolder = metadata.folders[folderIndex];
metadata.folders.splice(folderIndex, 1);
metadata.version += 1;
await saveFolderMetadata(s3_client, tabloId, metadata);
// Note: Files in the folder are NOT deleted, they become "orphaned" / unorganized
return c.json({
message: "Folder deleted successfully",
folder: deletedFolder,
});
} catch (error) {
console.error("Error deleting folder:", error);
return c.json({ error: "Failed to delete folder" }, 500);
}
});
// ============================================
// ROUTER SETUP
// ============================================
export const getTabloDataRouter = () => {
const tabloDataRouter = new Hono();
const middlewareManager = MiddlewareManager.getInstance();
tabloDataRouter.use(middlewareManager.auth);
tabloDataRouter.use(middlewareManager.r2);
// All-tablos file listing (must be before /:tabloId routes)
tabloDataRouter.get("/all-filenames", ...getAllTablosFilenames);
// File endpoints
tabloDataRouter.get("/:tabloId/filenames", ...getTabloFilenames);
// Folder endpoints (must be defined before wildcard file routes)
tabloDataRouter.get("/:tabloId/folders", ...getTabloFolders);
tabloDataRouter.post("/:tabloId/folders", ...createTabloFolder(middlewareManager));
tabloDataRouter.put("/:tabloId/folders/:folderId", ...updateTabloFolder(middlewareManager));
tabloDataRouter.delete("/:tabloId/folders/:folderId", ...deleteTabloFolder(middlewareManager));
// File routes using wildcard to support nested paths (e.g., "folder-123/file.pdf")
// These must be defined after the specific routes above
tabloDataRouter.get("/:tabloId/file/:path{.+}", ...getTabloFile);
tabloDataRouter.post("/:tabloId/file/:path{.+}", ...postTabloFile);
tabloDataRouter.delete("/:tabloId/file/:path{.+}", ...deleteTabloFile(middlewareManager));
// Legacy routes for backward compatibility (single-level file names only)
tabloDataRouter.get("/:tabloId/:fileName", ...getTabloFile);
tabloDataRouter.post("/:tabloId/:fileName", ...postTabloFile);
tabloDataRouter.delete("/:tabloId/:fileName", ...deleteTabloFile(middlewareManager));
return tabloDataRouter;
};