was `/todos` in the platform: a board, an API and a tree reader pointed at `resolve(process.cwd(), 'todo')`. that is not a setting — it is the belief that there is one project and it is the one the server happens to have been started in. THE READER CAME ACROSS UNCHANGED except for that one line. the frontmatter parser that never rewrites a body, the ordering, the index writer — all of it as it was, and its 8 tests came too and still pass. everything that changed is that the directory is now a parameter. `writeIndex` no longer writes to `process.cwd()` either. that was the same assumption wearing a different hat, and with two projects it would have had every board overwriting one file. the map goes beside the folder it describes. A PROJECT IS A NAME AND A PATH, and paths are confined to the owner's home by the same three checks av-club uses — absolute, realpath BEFORE the containment test, and `root` or `root + sep` rather than a bare startsWith. spelled out again rather than cross-referenced, because the next person to add a path-taking route reads the file they are in. it stores NO ITEMS. one table, a list of folders. uninstalling with "delete its data" ticked loses the list and never a line of anybody's work — which is the property the markdown format was chosen for. `/projects` is owner-only: pointing this at a directory is a statement about the filesystem. moving a card is not, so a member granted `kanban` can work any board they can see. removing a project removes the ROW. the route says so in its response and the button says so in its tooltip, because "delete" beside a folder full of somebody's work should not be ambiguous. README records the seam honestly: the platform keeps its own todo/ and its CLI, so there are two copies of the reader and they will drift. the alternative was making the platform's own backlog depend on a plugin being installed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
89 lines
4.0 KiB
TypeScript
89 lines
4.0 KiB
TypeScript
import { realpath, stat } from 'node:fs/promises';
|
|
import { isAbsolute, resolve, sep } from 'node:path';
|
|
import { db } from 'officerdb/db';
|
|
import { and, eq } from 'drizzle-orm';
|
|
import { getOwnerHomeDir } from '@@/data-path';
|
|
import { kanbanProjects, type KanbanProject } from '../db/schema';
|
|
|
|
/**
|
|
* The list of folders this board can show, and the rule about which folders those may be.
|
|
*
|
|
* ── Every path is confined to the owner's home ──
|
|
*
|
|
* A project path is caller-supplied and is then read from and WRITTEN to — `patchFrontmatter` edits
|
|
* files under it, and `writeIndex` creates one beside it. So it goes through the same three checks
|
|
* av-club's `paths.ts` applies, for the same reasons, and they are worth repeating rather than
|
|
* cross-referencing because the next person to add a path-taking route will read whichever file they
|
|
* are in:
|
|
*
|
|
* ABSOLUTE — a relative path resolves against a working directory the caller cannot see. That is
|
|
* precisely the bug this extraction exists to fix, so accepting one here would be reintroducing it
|
|
* through the front door.
|
|
* REALPATH FIRST — a symlink inside home pointing at `/etc` is resolved and then rejected. Checking
|
|
* the path as written and opening the resolved one is the classic way this fails.
|
|
* `root` OR `root + sep` — never a bare `startsWith`, which accepts `/home/user-backup` for a root
|
|
* of `/home/user`.
|
|
*/
|
|
|
|
const inside = (root: string, target: string) => target === root || target.startsWith(root + sep);
|
|
|
|
export class ProjectPathError extends Error {}
|
|
|
|
/**
|
|
* Validate a directory a caller wants to add.
|
|
*
|
|
* It must EXIST and be a directory. A project pointing at nothing is a row that produces an empty board
|
|
* with no explanation, and the moment to say so is while somebody is looking at the form.
|
|
*/
|
|
export async function safeProjectPath(raw: unknown, email: string): Promise<string> {
|
|
if (typeof raw !== 'string' || !raw.trim()) throw new ProjectPathError('A path is required.');
|
|
|
|
const home = getOwnerHomeDir(email);
|
|
const trimmed = raw.trim();
|
|
const expanded = trimmed === '~' ? home : trimmed.startsWith('~/') ? resolve(home, trimmed.slice(2)) : trimmed;
|
|
if (!isAbsolute(expanded)) throw new ProjectPathError('The path must be absolute.');
|
|
|
|
const real = await realpath(resolve(expanded)).catch(() => null);
|
|
// One message for "outside" and "missing" together: distinguishing them turns this into an oracle for
|
|
// what exists elsewhere on the disk.
|
|
if (!real || !inside(home, real)) throw new ProjectPathError('That is not a folder this board can read.');
|
|
|
|
const info = await stat(real).catch(() => null);
|
|
if (!info?.isDirectory()) throw new ProjectPathError('That path is not a directory.');
|
|
|
|
return real;
|
|
}
|
|
|
|
export const listProjects = (userId: number): Promise<KanbanProject[]> =>
|
|
db.select().from(kanbanProjects).where(eq(kanbanProjects.userId, userId)).orderBy(kanbanProjects.name);
|
|
|
|
/**
|
|
* One project, scoped to its owner.
|
|
*
|
|
* The `userId` is in the WHERE clause rather than checked after the read. A query that fetches by id
|
|
* and then compares is one `if` away from returning somebody else's row, and that `if` is exactly what
|
|
* gets dropped in a refactor.
|
|
*/
|
|
export async function getProject(userId: number, id: number): Promise<KanbanProject | null> {
|
|
const [row] = await db
|
|
.select()
|
|
.from(kanbanProjects)
|
|
.where(and(eq(kanbanProjects.userId, userId), eq(kanbanProjects.id, id)))
|
|
.limit(1);
|
|
return row ?? null;
|
|
}
|
|
|
|
export async function addProject(userId: number, name: string, path: string): Promise<KanbanProject> {
|
|
const [row] = await db.insert(kanbanProjects).values({ userId, name, path }).returning();
|
|
return row!;
|
|
}
|
|
|
|
/** Removes the ROW. Never the folder — this plugin does not delete anybody's todo files, ever. */
|
|
export async function removeProject(userId: number, id: number): Promise<boolean> {
|
|
const rows = await db
|
|
.delete(kanbanProjects)
|
|
.where(and(eq(kanbanProjects.userId, userId), eq(kanbanProjects.id, id)))
|
|
.returning({ id: kanbanProjects.id });
|
|
return rows.length > 0;
|
|
}
|