This commit is contained in:
Arthur Belleville 2025-12-18 11:25:00 +01:00
parent f32a0a19a7
commit 4347adedd9
No known key found for this signature in database
10 changed files with 1949 additions and 401 deletions

View file

@ -5,17 +5,26 @@ import {
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import type { TabloFoldersMetadata } from "@xtablo/shared-types";
import { sdkStreamMixin } from "@smithy/util-stream";
import { mockClient } from "aws-sdk-client-mock";
import { testClient } from "hono/testing";
import { Readable } from "stream";
import { beforeAll, describe, expect, it } from "vitest";
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
import type { TestUserData } from "../helpers/dbSetup.js";
import { getTestUser } from "../helpers/dbSetup.js";
// Helper to create S3 stream from content
const createS3Stream = (content: string) => {
const stream = new Readable();
stream.push(content);
stream.push(null);
return sdkStreamMixin(stream);
};
// Create S3 mock
const s3Mock = mockClient(S3Client);
@ -43,6 +52,9 @@ describe("TabloData Endpoint", () => {
return res;
};
// Store folder metadata state for tests
let mockFolderMetadata: TabloFoldersMetadata = { folders: [], version: 1 };
beforeAll(() => {
// Reset mocks before all tests
s3Mock.reset();
@ -56,26 +68,48 @@ describe("TabloData Endpoint", () => {
],
});
// Mock GetObjectCommand (used by getTabloFile)
// Create a proper SDK stream from Readable
const stream = new Readable();
stream.push("test file content");
stream.push(null);
const sdkStream = sdkStreamMixin(stream);
s3Mock.on(GetObjectCommand).resolves({
Body: sdkStream,
ContentType: "text/plain",
LastModified: new Date("2025-11-12"),
// Mock GetObjectCommand (used by getTabloFile and getFolderMetadata)
s3Mock.on(GetObjectCommand).callsFake((input) => {
// Handle folder metadata requests
if (input.Key?.endsWith(".tablo-folders.json")) {
if (mockFolderMetadata.folders.length === 0 && mockFolderMetadata.version === 1) {
// Return empty metadata (simulates file not found)
return Promise.resolve({
Body: createS3Stream(JSON.stringify(mockFolderMetadata)),
ContentType: "application/json",
});
}
return Promise.resolve({
Body: createS3Stream(JSON.stringify(mockFolderMetadata)),
ContentType: "application/json",
});
}
// Handle regular file requests
return Promise.resolve({
Body: createS3Stream("test file content"),
ContentType: "text/plain",
LastModified: new Date("2025-11-12"),
});
});
// Mock PutObjectCommand (used by postTabloFile)
s3Mock.on(PutObjectCommand).resolves({});
// Mock PutObjectCommand (used by postTabloFile and saveFolderMetadata)
s3Mock.on(PutObjectCommand).callsFake((input) => {
// Handle folder metadata saves
if (input.Key?.endsWith(".tablo-folders.json") && input.Body) {
mockFolderMetadata = JSON.parse(input.Body as string);
}
return Promise.resolve({});
});
// Mock DeleteObjectCommand (used by deleteTabloFile)
s3Mock.on(DeleteObjectCommand).resolves({});
});
beforeEach(() => {
// Reset folder metadata before each test
mockFolderMetadata = { folders: [], version: 1 };
});
describe("GET /tablo-data/:tabloId/filenames - Owner Access", () => {
it("should allow owner to access their private tablo", async () => {
const res = await getTabloFileNamesResponse(ownerUser, client, "test_tablo_owner_private");
@ -491,4 +525,632 @@ describe("TabloData Endpoint", () => {
});
});
});
// ============================================
// FOLDER ENDPOINT TESTS
// ============================================
describe("GET /tablo-data/:tabloId/folders - Get Folders (Member Access)", () => {
// Helper function to get folders
const getFoldersRequest = async (
user: TestUserData,
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
client: any,
tabloId: string
) => {
return await client["tablo-data"][":tabloId"]["folders"].$get(
{ param: { tabloId } },
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
);
};
describe("Owner Access", () => {
it("should allow owner to get folders from their own tablo", async () => {
const res = await getFoldersRequest(ownerUser, client, "test_tablo_owner_private");
expect(res.status).toBe(200);
const data = await res.json();
expect(data.folders).toEqual([]);
expect(data.version).toBe(1);
});
it("should allow owner to get folders from their shared tablo", async () => {
const res = await getFoldersRequest(ownerUser, client, "test_tablo_owner_shared");
expect(res.status).toBe(200);
});
});
describe("Temp User Access", () => {
it("should allow temp user to get folders from owner's shared tablo", async () => {
const res = await getFoldersRequest(temporaryUser, client, "test_tablo_owner_shared");
expect(res.status).toBe(200);
});
it("should deny temp user access to owner's private tablo folders", async () => {
const res = await getFoldersRequest(temporaryUser, client, "test_tablo_owner_private");
expect(res.status).toBe(403);
});
});
describe("Unauthenticated Access", () => {
it("should deny unauthenticated access to folders", async () => {
const res = await client["tablo-data"][":tabloId"]["folders"].$get({
param: { tabloId: "test_tablo_owner_private" },
});
expect(res.status).toBe(401);
});
});
});
describe("POST /tablo-data/:tabloId/folders - Create Folder (Admin Only)", () => {
// Helper function to create folder
const createFolderRequest = async (
user: TestUserData,
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
client: any,
tabloId: string,
name: string,
description?: string
) => {
return await client["tablo-data"][":tabloId"]["folders"].$post(
{ param: { tabloId }, json: { name, description } },
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
);
};
describe("Owner with Admin Access", () => {
it("should allow owner to create folder in their own tablo", async () => {
const res = await createFolderRequest(
ownerUser,
client,
"test_tablo_owner_private",
"My Folder",
"Test description"
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe("Folder created successfully");
expect(data.folder.name).toBe("My Folder");
expect(data.folder.description).toBe("Test description");
expect(data.folder.id).toMatch(/^folder-/);
});
it("should allow owner to create folder without description", async () => {
const res = await createFolderRequest(
ownerUser,
client,
"test_tablo_owner_private",
"Folder Without Description"
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.folder.name).toBe("Folder Without Description");
expect(data.folder.description).toBeUndefined();
});
});
describe("Validation", () => {
it("should return 400 if folder name is missing", async () => {
const res = await client["tablo-data"][":tabloId"]["folders"].$post(
{ param: { tabloId: "test_tablo_owner_private" }, json: {} },
{ headers: { Authorization: `Bearer ${ownerUser.accessToken}` } }
);
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toBe("Folder name is required");
});
it("should return 400 if folder name is empty string", async () => {
const res = await createFolderRequest(
ownerUser,
client,
"test_tablo_owner_private",
" "
);
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toBe("Folder name is required");
});
it("should return 400 for duplicate folder names", async () => {
// Create first folder
await createFolderRequest(ownerUser, client, "test_tablo_owner_private", "Duplicate Name");
// Try to create folder with same name
const res = await createFolderRequest(
ownerUser,
client,
"test_tablo_owner_private",
"duplicate name" // Case insensitive check
);
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toBe("A folder with this name already exists");
});
});
describe("Temp User - Blocked by regularUserCheck", () => {
it("should deny temp user from creating folder (regularUserCheck)", async () => {
const res = await createFolderRequest(
temporaryUser,
client,
"test_tablo_temp_private",
"Temp Folder"
);
// Temporary users are blocked by regularUserCheck middleware
expect(res.status).toBe(401);
});
});
describe("Unauthenticated Access", () => {
it("should deny unauthenticated folder creation", async () => {
const res = await client["tablo-data"][":tabloId"]["folders"].$post({
param: { tabloId: "test_tablo_owner_private" },
json: { name: "Test Folder" },
});
expect(res.status).toBe(401);
});
});
});
describe("PUT /tablo-data/:tabloId/folders/:folderId - Update Folder (Admin Only)", () => {
// Helper function to update folder
const updateFolderRequest = async (
user: TestUserData,
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
client: any,
tabloId: string,
folderId: string,
name?: string,
description?: string
) => {
return await client["tablo-data"][":tabloId"]["folders"][":folderId"].$put(
{ param: { tabloId, folderId }, json: { name, description } },
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
);
};
// Helper to create a folder and return its ID
const createTestFolder = async (tabloId: string, name: string): Promise<string> => {
const res = await client["tablo-data"][":tabloId"]["folders"].$post(
{ param: { tabloId }, json: { name } },
{ headers: { Authorization: `Bearer ${ownerUser.accessToken}` } }
);
const data = await res.json();
return data.folder.id;
};
describe("Owner with Admin Access", () => {
it("should allow owner to update folder name", async () => {
const folderId = await createTestFolder("test_tablo_owner_private", "Original Name");
const res = await updateFolderRequest(
ownerUser,
client,
"test_tablo_owner_private",
folderId,
"Updated Name"
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe("Folder updated successfully");
expect(data.folder.name).toBe("Updated Name");
});
it("should allow owner to update folder description", async () => {
const folderId = await createTestFolder("test_tablo_owner_private", "Test Folder");
const res = await updateFolderRequest(
ownerUser,
client,
"test_tablo_owner_private",
folderId,
undefined,
"New description"
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.folder.description).toBe("New description");
});
it("should allow owner to clear folder description", async () => {
const folderId = await createTestFolder("test_tablo_owner_private", "Test Folder");
// First set a description
await updateFolderRequest(
ownerUser,
client,
"test_tablo_owner_private",
folderId,
undefined,
"Some description"
);
// Then clear it
const res = await updateFolderRequest(
ownerUser,
client,
"test_tablo_owner_private",
folderId,
undefined,
""
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.folder.description).toBeUndefined();
});
});
describe("Validation", () => {
it("should return 404 for non-existent folder", async () => {
const res = await updateFolderRequest(
ownerUser,
client,
"test_tablo_owner_private",
"non-existent-folder-id",
"New Name"
);
expect(res.status).toBe(404);
const data = await res.json();
expect(data.error).toBe("Folder not found");
});
it("should return 400 for duplicate folder names on update", async () => {
const folderId1 = await createTestFolder("test_tablo_owner_private", "Folder One");
await createTestFolder("test_tablo_owner_private", "Folder Two");
// Try to rename folder 1 to folder 2's name
const res = await updateFolderRequest(
ownerUser,
client,
"test_tablo_owner_private",
folderId1,
"Folder Two"
);
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toBe("A folder with this name already exists");
});
});
describe("Temp User - Blocked by regularUserCheck", () => {
it("should deny temp user from updating folder (regularUserCheck)", async () => {
const res = await updateFolderRequest(
temporaryUser,
client,
"test_tablo_temp_private",
"some-folder-id",
"New Name"
);
expect(res.status).toBe(401);
});
});
describe("Unauthenticated Access", () => {
it("should deny unauthenticated folder update", async () => {
const res = await client["tablo-data"][":tabloId"]["folders"][":folderId"].$put({
param: { tabloId: "test_tablo_owner_private", folderId: "some-id" },
json: { name: "New Name" },
});
expect(res.status).toBe(401);
});
});
});
describe("DELETE /tablo-data/:tabloId/folders/:folderId - Delete Folder (Admin Only)", () => {
// Helper function to delete folder
const deleteFolderRequest = async (
user: TestUserData,
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
client: any,
tabloId: string,
folderId: string
) => {
return await client["tablo-data"][":tabloId"]["folders"][":folderId"].$delete(
{ param: { tabloId, folderId } },
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
);
};
// Helper to create a folder and return its ID
const createTestFolder = async (tabloId: string, name: string): Promise<string> => {
const res = await client["tablo-data"][":tabloId"]["folders"].$post(
{ param: { tabloId }, json: { name } },
{ headers: { Authorization: `Bearer ${ownerUser.accessToken}` } }
);
const data = await res.json();
return data.folder.id;
};
describe("Owner with Admin Access", () => {
it("should allow owner to delete folder from their own tablo", async () => {
const folderId = await createTestFolder("test_tablo_owner_private", "To Delete");
const res = await deleteFolderRequest(
ownerUser,
client,
"test_tablo_owner_private",
folderId
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe("Folder deleted successfully");
expect(data.folder.name).toBe("To Delete");
});
});
describe("Validation", () => {
it("should return 404 for non-existent folder", async () => {
const res = await deleteFolderRequest(
ownerUser,
client,
"test_tablo_owner_private",
"non-existent-folder-id"
);
expect(res.status).toBe(404);
const data = await res.json();
expect(data.error).toBe("Folder not found");
});
});
describe("Temp User - Blocked by regularUserCheck", () => {
it("should deny temp user from deleting folder (regularUserCheck)", async () => {
const res = await deleteFolderRequest(
temporaryUser,
client,
"test_tablo_temp_private",
"some-folder-id"
);
expect(res.status).toBe(401);
});
});
describe("Unauthenticated Access", () => {
it("should deny unauthenticated folder deletion", async () => {
const res = await client["tablo-data"][":tabloId"]["folders"][":folderId"].$delete({
param: { tabloId: "test_tablo_owner_private", folderId: "some-id" },
});
expect(res.status).toBe(401);
});
});
});
// ============================================
// NESTED FILE PATH TESTS (/:tabloId/file/:path)
// ============================================
describe("Nested File Path Routes (/file/:path)", () => {
// Helper function to get file with nested path
const getNestedFileRequest = async (
user: TestUserData,
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
client: any,
tabloId: string,
filePath: string
) => {
return await client["tablo-data"][":tabloId"]["file"][":path"].$get(
{ param: { tabloId, path: filePath } },
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
);
};
// Helper function to post file with nested path
const postNestedFileRequest = async (
user: TestUserData,
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
client: any,
tabloId: string,
filePath: string,
content: string
) => {
return await client["tablo-data"][":tabloId"]["file"][":path"].$post(
{ param: { tabloId, path: filePath }, json: { content, contentType: "text/plain" } },
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
);
};
// Helper function to delete file with nested path
const deleteNestedFileRequest = async (
user: TestUserData,
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
client: any,
tabloId: string,
filePath: string
) => {
return await client["tablo-data"][":tabloId"]["file"][":path"].$delete(
{ param: { tabloId, path: filePath } },
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
);
};
describe("GET /tablo-data/:tabloId/file/:path - Get File with Nested Path", () => {
it("should allow owner to get file with nested path", async () => {
const res = await getNestedFileRequest(
ownerUser,
client,
"test_tablo_owner_private",
"folder-123/document.pdf"
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.fileName).toBe("folder-123/document.pdf");
expect(data.content).toBe("test file content");
});
it("should allow owner to get file with deeply nested path", async () => {
const res = await getNestedFileRequest(
ownerUser,
client,
"test_tablo_owner_private",
"folder-123/subfolder/deep/file.txt"
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.fileName).toBe("folder-123/subfolder/deep/file.txt");
});
it("should allow member to get nested file from shared tablo", async () => {
const res = await getNestedFileRequest(
temporaryUser,
client,
"test_tablo_owner_shared",
"folder-456/report.pdf"
);
expect(res.status).toBe(200);
});
it("should deny access to nested file in private tablo", async () => {
const res = await getNestedFileRequest(
temporaryUser,
client,
"test_tablo_owner_private",
"folder-123/secret.pdf"
);
expect(res.status).toBe(403);
});
it("should deny unauthenticated access to nested file", async () => {
const res = await client["tablo-data"][":tabloId"]["file"][":path"].$get({
param: { tabloId: "test_tablo_owner_private", path: "folder/file.txt" },
});
expect(res.status).toBe(401);
});
});
describe("POST /tablo-data/:tabloId/file/:path - Upload File with Nested Path", () => {
it("should allow owner to upload file with nested path", async () => {
const res = await postNestedFileRequest(
ownerUser,
client,
"test_tablo_owner_private",
"folder-123/new-document.pdf",
"Document content"
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe("File uploaded successfully");
expect(data.fileName).toBe("folder-123/new-document.pdf");
});
it("should allow member to upload file with nested path to shared tablo", async () => {
const res = await postNestedFileRequest(
temporaryUser,
client,
"test_tablo_owner_shared",
"folder-789/upload.txt",
"Uploaded content"
);
expect(res.status).toBe(200);
});
it("should deny member from uploading nested file to private tablo", async () => {
const res = await postNestedFileRequest(
temporaryUser,
client,
"test_tablo_owner_private",
"folder-123/unauthorized.txt",
"Should fail"
);
expect(res.status).toBe(403);
});
it("should deny unauthenticated nested file upload", async () => {
const res = await client["tablo-data"][":tabloId"]["file"][":path"].$post({
param: { tabloId: "test_tablo_owner_private", path: "folder/file.txt" },
json: { content: "test" },
});
expect(res.status).toBe(401);
});
});
describe("DELETE /tablo-data/:tabloId/file/:path - Delete File with Nested Path (Admin Only)", () => {
it("should allow admin to delete file with nested path", async () => {
const res = await deleteNestedFileRequest(
ownerUser,
client,
"test_tablo_owner_private",
"folder-123/to-delete.pdf"
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe("File deleted successfully");
expect(data.fileName).toBe("folder-123/to-delete.pdf");
});
it("should allow admin to delete deeply nested file", async () => {
const res = await deleteNestedFileRequest(
ownerUser,
client,
"test_tablo_owner_private",
"folder-123/level2/level3/deep-file.txt"
);
expect(res.status).toBe(200);
});
it("should deny temp user from deleting nested file (regularUserCheck)", async () => {
const res = await deleteNestedFileRequest(
temporaryUser,
client,
"test_tablo_temp_private",
"folder-123/file.pdf"
);
expect(res.status).toBe(401);
});
it("should deny non-admin from deleting nested file", async () => {
// Owner has admin access to test_tablo_temp_shared_admin
// BUT regularUserCheck blocks access to tablos owned by temporary users
const res = await deleteNestedFileRequest(
ownerUser,
client,
"test_tablo_temp_shared_admin",
"folder/file.pdf"
);
expect(res.status).toBe(403);
});
it("should deny unauthenticated nested file deletion", async () => {
const res = await client["tablo-data"][":tabloId"]["file"][":path"].$delete({
param: { tabloId: "test_tablo_owner_private", path: "folder/file.txt" },
});
expect(res.status).toBe(401);
});
});
});
});

View file

@ -116,11 +116,13 @@ export const getTabloFileNames = async (s3_client: S3Client, tabloId: string) =>
const { Contents } = await s3_client.send(
new ListObjectsV2Command({
Bucket: bucketName,
Prefix: tabloId,
Prefix: `${tabloId}/`,
})
);
return Contents?.map((content) => content.Key?.split("/")[1]).filter(
// Return full file paths relative to tabloId (e.g., "file.pdf" or "folder-123/file.pdf")
const prefix = `${tabloId}/`;
return Contents?.map((content) => content.Key?.substring(prefix.length)).filter(
(content) => content?.length && content.length > 0
);
};

View file

@ -45,7 +45,7 @@ async function startServer(secrets: Secrets) {
"Access-Control-Allow-Credentials",
"Access-Control-Expose-Headers",
],
allowMethods: ["GET", "POST", "PATCH", "OPTIONS", "DELETE"],
allowMethods: ["GET", "POST", "PUT", "PATCH", "OPTIONS", "DELETE"],
exposeHeaders: ["set-cookie"],
credentials: true,
});

View file

@ -1,4 +1,10 @@
import { PutObjectCommand } from "@aws-sdk/client-s3";
import {
DeleteObjectCommand,
GetObjectCommand,
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";
@ -7,6 +13,16 @@ import type { AuthEnv } from "../types/app.types.js";
const factory = createFactory<AuthEnv>();
// Metadata file name for folders
const FOLDERS_METADATA_FILE = ".tablo-folders.json";
// Helper to generate unique folder IDs
const generateFolderId = () => `folder-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
// ============================================
// FILE ENDPOINTS
// ============================================
const getTabloFilenames = factory.createHandlers(checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId");
const s3_client = c.get("s3_client");
@ -22,17 +38,20 @@ const getTabloFilenames = factory.createHandlers(checkTabloMember, async (c) =>
const getTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId");
const fileName = c.req.param("fileName");
// 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 { GetObjectCommand } = await import("@aws-sdk/client-s3");
const response = await s3_client.send(
new GetObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${fileName}`,
Key: `${tabloId}/${filePath}`,
})
);
@ -43,7 +62,7 @@ const getTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
const content = await response.Body.transformToString();
return c.json({
fileName,
fileName: filePath,
content,
contentType: response.ContentType,
lastModified: response.LastModified,
@ -56,7 +75,12 @@ const getTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId");
const fileName = c.req.param("fileName");
// 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");
@ -71,7 +95,7 @@ const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
await s3_client.send(
new PutObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${fileName}`,
Key: `${tabloId}/${filePath}`,
Body: content,
ContentType: contentType,
})
@ -79,7 +103,7 @@ const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
return c.json({
message: "File uploaded successfully",
fileName,
fileName: filePath,
tabloId,
});
} catch (error) {
@ -88,61 +112,29 @@ const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
}
});
// // PUT /tablo-data/:tabloId/:fileName - Update a file
// tabloDataRouter.put("/:tabloId/:fileName", async (c) => {
// const tabloId = c.req.param("tabloId");
// const fileName = c.req.param("fileName");
// 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);
// }
// const { PutObjectCommand } = await import("@aws-sdk/client-s3");
// await s3_client.send(
// new PutObjectCommand({
// Bucket: "tablo-data",
// Key: `${tabloId}/${fileName}`,
// Body: content,
// ContentType: contentType,
// })
// );
// return c.json({
// message: "File updated successfully",
// fileName,
// tabloId,
// });
// } catch (error) {
// console.error("Error updating file:", error);
// return c.json({ error: "Failed to update file" }, 500);
// }
// });
const deleteTabloFile = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
const tabloId = c.req.param("tabloId");
const fileName = c.req.param("fileName");
// 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 { DeleteObjectCommand } = await import("@aws-sdk/client-s3");
await s3_client.send(
new DeleteObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${fileName}`,
Key: `${tabloId}/${filePath}`,
})
);
return c.json({
message: "File deleted successfully",
fileName,
fileName: filePath,
tabloId,
});
} catch (error) {
@ -151,6 +143,193 @@ const deleteTabloFile = (middlewareManager: ReturnType<typeof MiddlewareManager.
}
});
// ============================================
// FOLDER ENDPOINTS
// ============================================
// Helper to get or create folder metadata
const getFolderMetadata = async (
s3_client: S3Client,
tabloId: string
): Promise<TabloFoldersMetadata> => {
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();
return JSON.parse(content);
}
} catch {
// File doesn't exist, return default
}
return { folders: [], version: 1 };
};
// 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",
})
);
};
// 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();
@ -159,7 +338,22 @@ export const getTabloDataRouter = () => {
tabloDataRouter.use(middlewareManager.streamChat);
tabloDataRouter.use(middlewareManager.r2);
// 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));

File diff suppressed because it is too large Load diff

View file

@ -140,7 +140,7 @@ export function useCreateTabloFile() {
{ tabloId: string; fileName: string; data: FileUploadRequest }
>({
mutationFn: async ({ tabloId, fileName, data }) => {
const response = await api.post(`/api/v1/tablo-data/${tabloId}/${fileName}`, data);
const response = await api.post(`/api/v1/tablo-data/${tabloId}/file/${fileName}`, data);
if (response.status !== 200) {
throw new Error("Failed to create file");
}
@ -185,7 +185,7 @@ export function useUpdateTabloFile() {
{ tabloId: string; fileName: string; data: FileUploadRequest }
>({
mutationFn: async ({ tabloId, fileName, data }) => {
const response = await api.put(`/api/v1/tablo-data/${tabloId}/${fileName}`, data);
const response = await api.put(`/api/v1/tablo-data/${tabloId}/file/${fileName}`, data);
if (response.status !== 200) {
throw new Error("Failed to update file");
}
@ -226,7 +226,7 @@ export function useDeleteTabloFile() {
return useMutation<FileOperationResponse, Error, { tabloId: string; fileName: string }>({
mutationFn: async ({ tabloId, fileName }) => {
const response = await api.delete(`/api/v1/tablo-data/${tabloId}/${fileName}`);
const response = await api.delete(`/api/v1/tablo-data/${tabloId}/file/${fileName}`);
if (response.status !== 200) {
throw new Error("Failed to delete file");
}

View file

@ -0,0 +1,191 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "@xtablo/shared";
import type { TabloFolder, TabloFoldersMetadata } from "@xtablo/shared-types";
import { useAuthedApi } from "./auth";
// Re-export types for convenience
export type { TabloFolder, TabloFoldersMetadata } from "@xtablo/shared-types";
const toastOptions = { timeout: 5000 };
// Hook to get folders for a tablo
export function useTabloFolders(tabloId: string) {
const api = useAuthedApi();
return useQuery<TabloFoldersMetadata>({
queryKey: ["tablo-folders", tabloId],
queryFn: async () => {
const response = await api.get(`/api/v1/tablo-data/${tabloId}/folders`);
if (response.status === 200) {
return response.data;
}
return { folders: [], version: 1 };
},
enabled: !!tabloId,
});
}
// Hook to create a new folder
export function useCreateTabloFolder() {
const api = useAuthedApi();
const queryClient = useQueryClient();
return useMutation<
TabloFolder,
Error,
{ tabloId: string; name: string; description?: string; createdBy: string }
>({
mutationFn: async ({ tabloId, name, description }) => {
const response = await api.post(`/api/v1/tablo-data/${tabloId}/folders`, {
name,
description,
});
if (response.status !== 200) {
throw new Error(response.data?.error || "Failed to create folder");
}
return response.data.folder;
},
onSuccess: (folder) => {
toast.add(
{
title: "Livrable créé",
description: `Le livrable "${folder.name}" a été créé avec succès`,
type: "success",
},
toastOptions
);
},
onError: (error) => {
toast.add(
{
title: "Erreur",
description: `Échec de la création du livrable: ${error.message}`,
type: "error",
},
toastOptions
);
},
onSettled: (_, _err, variables) => {
if (variables) {
queryClient.invalidateQueries({
queryKey: ["tablo-folders", variables.tabloId],
});
}
},
});
}
// Hook to update a folder
export function useUpdateTabloFolder() {
const api = useAuthedApi();
const queryClient = useQueryClient();
return useMutation<
TabloFolder,
Error,
{ tabloId: string; folderId: string; name?: string; description?: string }
>({
mutationFn: async ({ tabloId, folderId, name, description }) => {
const response = await api.put(`/api/v1/tablo-data/${tabloId}/folders/${folderId}`, {
name,
description,
});
if (response.status !== 200) {
throw new Error(response.data?.error || "Failed to update folder");
}
return response.data.folder;
},
onSuccess: (folder) => {
toast.add(
{
title: "Livrable mis à jour",
description: `Le livrable "${folder.name}" a été mis à jour`,
type: "success",
},
toastOptions
);
},
onError: (error) => {
toast.add(
{
title: "Erreur",
description: `Échec de la mise à jour du livrable: ${error.message}`,
type: "error",
},
toastOptions
);
},
onSettled: (_, _err, variables) => {
if (variables) {
queryClient.invalidateQueries({
queryKey: ["tablo-folders", variables.tabloId],
});
}
},
});
}
// Hook to delete a folder
export function useDeleteTabloFolder() {
const api = useAuthedApi();
const queryClient = useQueryClient();
return useMutation<void, Error, { tabloId: string; folderId: string; folderName: string }>({
mutationFn: async ({ tabloId, folderId }) => {
const response = await api.delete(`/api/v1/tablo-data/${tabloId}/folders/${folderId}`);
if (response.status !== 200) {
throw new Error(response.data?.error || "Failed to delete folder");
}
},
onSuccess: (_, variables) => {
toast.add(
{
title: "Livrable supprimé",
description: `Le livrable "${variables.folderName}" a été supprimé`,
type: "success",
},
toastOptions
);
},
onError: (error) => {
toast.add(
{
title: "Erreur",
description: `Échec de la suppression du livrable: ${error.message}`,
type: "error",
},
toastOptions
);
},
onSettled: (_, _err, variables) => {
if (variables) {
queryClient.invalidateQueries({
queryKey: ["tablo-folders", variables.tabloId],
});
}
},
});
}
// Helper function to get folder path prefix for a file
export const getFolderFilePrefix = (folderId: string) => `${folderId}/`;
// Helper to extract folder ID from a file name
export const extractFolderIdFromFileName = (fileName: string): string | null => {
const match = fileName.match(/^(folder-[^/]+)\//);
return match ? match[1] : null;
};
// Helper to get the actual file name without folder prefix
export const getFileNameWithoutFolder = (fileName: string): string => {
const folderId = extractFolderIdFromFileName(fileName);
if (folderId) {
return fileName.substring(folderId.length + 1);
}
return fileName;
};

View file

@ -10,6 +10,7 @@
"./database": "./src/database.types.ts",
"./events": "./src/events.types.ts",
"./tablos": "./src/tablos.types.ts",
"./tablo-data": "./src/tablo-data.types.ts",
"./stripe": "./src/stripe.types.ts",
"./kanban": "./src/kanban.types.ts",
"./utils": "./src/utils.ts"

View file

@ -52,6 +52,10 @@ export type {
// ============================================================================
export type { CreateTablo, Tablo, TabloInsert, TabloUpdate, UserTablo } from "./tablos.types.js";
// ============================================================================
// Tablo Data Types (Files and Folders)
// ============================================================================
export type { TabloFolder, TabloFoldersMetadata } from "./tablo-data.types.js";
// ============================================================================
// Utility Types
// ============================================================================
export type {

View file

@ -0,0 +1,23 @@
// ============================================================================
// Tablo Data Types (Files and Folders)
// ============================================================================
/**
* Represents a folder within a tablo for organizing files/deliverables
*/
export interface TabloFolder {
id: string;
name: string;
description?: string;
createdAt: string;
createdBy: string;
}
/**
* Metadata file structure for storing folder information in S3
*/
export interface TabloFoldersMetadata {
folders: TabloFolder[];
version: number;
}