497 lines
16 KiB
TypeScript
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;
|
|
};
|