Initial commit — Drive, an S3 file browser with WOPI editing
Lightweight replacement for the upstream La Suite Numérique drive (Django/Celery/Next.js) built as a single Deno binary. Server (Deno + Hono): - S3 file operations via AWS SigV4 (no SDK) with pre-signed URLs - WOPI host for Collabora Online (CheckFileInfo, GetFile, PutFile, locks) - Ory Kratos session auth + CSRF protection - Ory Keto permission model (OPL namespaces, not yet wired to routes) - PostgreSQL metadata with recursive folder sizes - S3 backfill API for registering files uploaded outside the UI - OpenTelemetry tracing + metrics (opt-in via OTEL_ENABLED) Frontend (React 19 + Cunningham v4 + react-aria): - File browser with GridList, keyboard nav, multi-select - Collabora editor iframe (full-screen, form POST, postMessage) - Profile menu, waffle menu, drag-drop upload, asset type badges - La Suite integration service theming (runtime CSS) Testing (549 tests): - 235 server unit tests (Deno) — 90%+ coverage - 278 UI unit tests (Vitest) — 90%+ coverage - 11 E2E tests (Playwright) - 12 integration service tests (Playwright) - 13 WOPI integration tests (Playwright + Docker Compose + Collabora) MIT licensed.
This commit is contained in:
103
server/folders.ts
Normal file
103
server/folders.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Folder operation handlers for Hono routes.
|
||||
*/
|
||||
|
||||
import type { Context } from "hono";
|
||||
import sql from "./db.ts";
|
||||
|
||||
interface Identity {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
function getIdentity(c: Context): Identity | null {
|
||||
const identity = c.get("identity");
|
||||
if (!identity?.id) return null;
|
||||
return identity as Identity;
|
||||
}
|
||||
|
||||
/** POST /api/folders — create a folder (DB record only, no S3 object) */
|
||||
export async function createFolder(c: Context): Promise<Response> {
|
||||
const identity = getIdentity(c);
|
||||
if (!identity) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const body = await c.req.json();
|
||||
const { name, parent_id } = body;
|
||||
if (!name) return c.json({ error: "name required" }, 400);
|
||||
|
||||
// Build s3_key for the folder (convention: ends with /)
|
||||
const pathParts = [identity.id, "my-files"];
|
||||
if (parent_id) {
|
||||
const parentPath = await buildPathFromParent(parent_id, identity.id);
|
||||
if (parentPath) pathParts.push(parentPath);
|
||||
}
|
||||
pathParts.push(name);
|
||||
const s3Key = pathParts.join("/") + "/";
|
||||
|
||||
const [folder] = await sql`
|
||||
INSERT INTO files (s3_key, filename, mimetype, size, owner_id, parent_id, is_folder)
|
||||
VALUES (${s3Key}, ${name}, ${"inode/directory"}, ${0}, ${identity.id}, ${parent_id || null}, ${true})
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
return c.json({ folder }, 201);
|
||||
}
|
||||
|
||||
/** GET /api/folders/:id/children — list folder contents (sorted, paginated) */
|
||||
export async function listFolderChildren(c: Context): Promise<Response> {
|
||||
const identity = getIdentity(c);
|
||||
if (!identity) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const id = c.req.param("id");
|
||||
const sort = c.req.query("sort") || "filename";
|
||||
const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200);
|
||||
const offset = parseInt(c.req.query("offset") || "0", 10);
|
||||
|
||||
// Verify folder exists and belongs to user
|
||||
const [folder] = await sql`
|
||||
SELECT id FROM files
|
||||
WHERE id = ${id} AND owner_id = ${identity.id} AND is_folder = true AND deleted_at IS NULL
|
||||
`;
|
||||
if (!folder) return c.json({ error: "Folder not found" }, 404);
|
||||
|
||||
const allowedSorts: Record<string, string> = {
|
||||
filename: "filename ASC",
|
||||
"-filename": "filename DESC",
|
||||
size: "size ASC",
|
||||
"-size": "size DESC",
|
||||
created_at: "created_at ASC",
|
||||
"-created_at": "created_at DESC",
|
||||
updated_at: "updated_at ASC",
|
||||
"-updated_at": "updated_at DESC",
|
||||
};
|
||||
const orderBy = allowedSorts[sort] ?? "filename ASC";
|
||||
|
||||
const rows = await sql.unsafe(
|
||||
`SELECT * FROM files
|
||||
WHERE parent_id = $1 AND owner_id = $2 AND deleted_at IS NULL
|
||||
ORDER BY is_folder DESC, ${orderBy}
|
||||
LIMIT $3 OFFSET $4`,
|
||||
[id, identity.id, limit, offset],
|
||||
);
|
||||
|
||||
return c.json({ files: rows });
|
||||
}
|
||||
|
||||
// ── Internal helpers ────────────────────────────────────────────────────────
|
||||
|
||||
async function buildPathFromParent(parentId: string, ownerId: string): Promise<string> {
|
||||
const parts: string[] = [];
|
||||
let currentId: string | null = parentId;
|
||||
|
||||
while (currentId) {
|
||||
const [folder] = await sql`
|
||||
SELECT id, filename, parent_id FROM files
|
||||
WHERE id = ${currentId} AND owner_id = ${ownerId} AND is_folder = true
|
||||
`;
|
||||
if (!folder) break;
|
||||
parts.unshift(folder.filename);
|
||||
currentId = folder.parent_id;
|
||||
}
|
||||
|
||||
return parts.join("/");
|
||||
}
|
||||
Reference in New Issue
Block a user