feat(server): REST API, auth, sources, changes, export
createApp() serves /api/v1 from a typed route table (zod bodies and queries), with session-cookie and Bearer app-token auth, a CSRF header for cookie writes, login/register rate limiting, body caps, JSON error mapping, OpenAPI generation and a 404 /ingest mount. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7b0a6738dd
commit
6963e297b0
@@ -0,0 +1,49 @@
|
||||
import { apiHandler, errorResponse, type AppDeps, type RouteDef } from "./http.ts";
|
||||
import { attachmentRoutes } from "./routes/attachments.ts";
|
||||
import { authRoutes } from "./routes/auth.ts";
|
||||
import { changeRoutes } from "./routes/changes.ts";
|
||||
import { copyRoutes } from "./routes/copies.ts";
|
||||
import { customFieldRoutes } from "./routes/custom-fields.ts";
|
||||
import { engagementRoutes } from "./routes/engagements.ts";
|
||||
import { entryRoutes } from "./routes/entries.ts";
|
||||
import { exportRoutes } from "./routes/export.ts";
|
||||
import { listRoutes } from "./routes/lists.ts";
|
||||
import { metaRoutes } from "./routes/meta.ts";
|
||||
import { sourceRoutes } from "./routes/sources.ts";
|
||||
import { workRoutes } from "./routes/works.ts";
|
||||
|
||||
export interface App {
|
||||
/** Handle `/api/v1/*` and `/ingest/*`. Anything else is a JSON 404 (the web app is served by `Bun.serve` routes). */
|
||||
fetch(req: Request, server?: { requestIP(req: Request): { address: string } | null }): Promise<Response>;
|
||||
routes: RouteDef[];
|
||||
}
|
||||
|
||||
/** The HTTP API, testable without a listening socket. */
|
||||
export function createApp(deps: AppDeps): App {
|
||||
const routes: RouteDef[] = [
|
||||
...authRoutes,
|
||||
...entryRoutes,
|
||||
...engagementRoutes,
|
||||
...workRoutes,
|
||||
...listRoutes,
|
||||
...copyRoutes,
|
||||
...customFieldRoutes,
|
||||
...attachmentRoutes,
|
||||
...sourceRoutes,
|
||||
...changeRoutes,
|
||||
...exportRoutes,
|
||||
];
|
||||
routes.push(...metaRoutes(() => routes));
|
||||
const api = apiHandler(routes, deps);
|
||||
|
||||
return {
|
||||
routes,
|
||||
async fetch(req, server) {
|
||||
const { pathname } = new URL(req.url);
|
||||
if (pathname.startsWith("/api/v1/")) return api(req, server?.requestIP(req)?.address ?? "unknown");
|
||||
// Phase 1: the ingest mount exists but no plugin has the `ingest` capability.
|
||||
if (pathname.startsWith("/ingest/")) return errorResponse(404, "not_found", "No ingest endpoint for this plugin.");
|
||||
return errorResponse(404, "not_found", "No such endpoint.");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface Config {
|
||||
databaseUrl: string;
|
||||
dataDir: string;
|
||||
secret: string;
|
||||
publicUrl: string | undefined;
|
||||
port: number;
|
||||
pluginDirs: string[];
|
||||
}
|
||||
|
||||
export const MISSING_SECRET = "Set STTRACKER_SECRET to a long random string. It encrypts source credentials.";
|
||||
|
||||
/** Read the server config from the environment. Throws when `STTRACKER_SECRET` is missing. */
|
||||
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
|
||||
const secret = env.STTRACKER_SECRET;
|
||||
if (!secret) throw new Error(MISSING_SECRET);
|
||||
const dataDir = env.DATA_DIR || "./data";
|
||||
const port = Number(env.PORT || 3000);
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`PORT must be a port number, got "${env.PORT}"`);
|
||||
return {
|
||||
databaseUrl: env.DATABASE_URL || `sqlite://${dataDir}/sttracker.db`,
|
||||
dataDir,
|
||||
secret,
|
||||
publicUrl: env.PUBLIC_URL || undefined,
|
||||
port,
|
||||
pluginDirs: (env.PLUGIN_DIRS || `plugins,${dataDir}/plugins`)
|
||||
.split(",")
|
||||
.map((d) => d.trim())
|
||||
.filter((d) => d !== ""),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { getSessionUser, verifyAppToken, type User } from "@sttracker/core/auth";
|
||||
import { AuthError, ImportError, NotFoundError, ValidationError } from "@sttracker/core/errors";
|
||||
import { isUniqueViolation } from "@sttracker/core/matching";
|
||||
import type { PluginRegistry } from "@sttracker/core/registry";
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
import type { TokenScope } from "@sttracker/db/schema";
|
||||
import { z } from "zod";
|
||||
|
||||
export type AuthMode = "none" | "session" | "token:read" | "token:write" | "any:read" | "any:write";
|
||||
export type Method = "GET" | "POST" | "PATCH" | "DELETE";
|
||||
|
||||
export interface AppDeps {
|
||||
db: Db;
|
||||
registry: PluginRegistry;
|
||||
secret: string;
|
||||
dataDir: string;
|
||||
/** When it starts with `https:`, the session cookie is `Secure`. */
|
||||
publicUrl?: string;
|
||||
}
|
||||
|
||||
export const SESSION_COOKIE = "stt_session";
|
||||
export const CSRF_HEADER = "x-requested-with";
|
||||
export const CSRF_VALUE = "sttracker";
|
||||
export const MAX_JSON_BYTES = 1024 * 1024;
|
||||
export const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
||||
const SESSION_MAX_AGE_S = 30 * 24 * 60 * 60;
|
||||
|
||||
export interface Ctx<B, Q> {
|
||||
req: Request;
|
||||
url: URL;
|
||||
params: Record<string, string>;
|
||||
body: B;
|
||||
query: Q;
|
||||
/** The authenticated user. Throws on `auth: "none"` routes, which have none. */
|
||||
user: User;
|
||||
/** The session token, when the request was authenticated by cookie. */
|
||||
sessionToken: string | null;
|
||||
/** Parsed multipart body, for `multipart: true` routes. */
|
||||
form: FormData;
|
||||
deps: AppDeps;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
export interface RouteDef<B = any, Q = any> {
|
||||
method: Method;
|
||||
/** Relative to `/api/v1`, with `:param` segments. */
|
||||
path: string;
|
||||
summary: string;
|
||||
auth: AuthMode;
|
||||
body?: z.ZodType<B>;
|
||||
query?: z.ZodType<Q>;
|
||||
/** Parse the body as multipart form data (upload limit) instead of JSON. */
|
||||
multipart?: boolean;
|
||||
/** Rate limit per client IP (login, register). */
|
||||
rateLimited?: boolean;
|
||||
/** Only admins may call it (403 otherwise). */
|
||||
admin?: boolean;
|
||||
handler(ctx: Ctx<B, Q>): Promise<unknown>;
|
||||
}
|
||||
|
||||
/** Declare a route; returns it unchanged with the handler's body and query types inferred from the schemas. */
|
||||
export function route<BS extends z.ZodType = z.ZodUnknown, QS extends z.ZodType = z.ZodUnknown>(
|
||||
def: Omit<RouteDef<z.output<BS>, z.output<QS>>, "body" | "query"> & { body?: BS; query?: QS },
|
||||
): RouteDef {
|
||||
return def as RouteDef;
|
||||
}
|
||||
|
||||
/** Throw from a handler to answer with a specific status and error code. */
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function json(data: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||
return new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json; charset=utf-8", ...headers } });
|
||||
}
|
||||
|
||||
export function errorResponse(status: number, code: string, message: string, headers: Record<string, string> = {}): Response {
|
||||
return json({ error: { code, message } }, status, headers);
|
||||
}
|
||||
|
||||
export const noContent = (): Response => new Response(null, { status: 204 });
|
||||
|
||||
const AUTH_STATUS: Record<string, [number, string, string]> = {
|
||||
invalid_credentials: [401, "unauthenticated", "Wrong username or password."],
|
||||
unauthenticated: [401, "unauthenticated", "Sign in first."],
|
||||
forbidden: [403, "forbidden", "You are not allowed to do that."],
|
||||
invite_required: [400, "invite_required", "Registration needs a valid invite code."],
|
||||
username_taken: [400, "username_taken", "That username is taken."],
|
||||
invalid_username: [400, "invalid_username", "Usernames are 2–32 characters: lowercase letters, digits and underscores."],
|
||||
};
|
||||
|
||||
/** Map a thrown error to the JSON error response (ruling 5). Unknown errors are logged and hidden. */
|
||||
export function toErrorResponse(e: unknown): Response {
|
||||
if (e instanceof HttpError) return errorResponse(e.status, e.code, e.message);
|
||||
if (e instanceof z.ZodError) {
|
||||
const msg = e.issues.map((i) => `${i.path.length ? i.path.join(".") : "body"}: ${i.message}`).join("; ");
|
||||
return errorResponse(400, "validation", msg);
|
||||
}
|
||||
if (e instanceof ValidationError) return errorResponse(400, "validation", `${e.field}: ${e.reason}`);
|
||||
if (e instanceof NotFoundError) return errorResponse(404, "not_found", e.message);
|
||||
if (e instanceof AuthError) {
|
||||
const [status, code, message] = AUTH_STATUS[e.code] ?? [403, "forbidden", "You are not allowed to do that."];
|
||||
return errorResponse(status, code, message);
|
||||
}
|
||||
if (e instanceof ImportError) {
|
||||
const status = e.code === "account_not_empty" || e.code === "conflict" ? 409 : 400;
|
||||
return errorResponse(status, e.code, `Import failed: ${e.code}.`);
|
||||
}
|
||||
if (isUniqueViolation(e)) return errorResponse(409, "conflict", "That already exists.");
|
||||
console.error("[api]", e);
|
||||
return errorResponse(500, "internal", "Something went wrong on the server.");
|
||||
}
|
||||
|
||||
export function sessionCookie(token: string, publicUrl: string | undefined): string {
|
||||
const secure = publicUrl?.startsWith("https:") ? "; Secure" : "";
|
||||
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE_S}${secure}`;
|
||||
}
|
||||
|
||||
export function clearSessionCookie(publicUrl: string | undefined): string {
|
||||
const secure = publicUrl?.startsWith("https:") ? "; Secure" : "";
|
||||
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`;
|
||||
}
|
||||
|
||||
function readCookie(req: Request, name: string): string | null {
|
||||
const header = req.headers.get("cookie");
|
||||
if (!header) return null;
|
||||
for (const part of header.split(";")) {
|
||||
const i = part.indexOf("=");
|
||||
if (i > 0 && part.slice(0, i).trim() === name) return decodeURIComponent(part.slice(i + 1).trim()) || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Read a request body, failing with 413 once it exceeds `max` bytes (whatever Content-Length claims). */
|
||||
async function readLimited(req: Request, max: number): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const tooLarge = () => new HttpError(413, "validation", `Request body is too large (max ${max / (1024 * 1024)} MB).`);
|
||||
const declared = Number(req.headers.get("content-length") ?? NaN);
|
||||
if (declared > max) throw tooLarge();
|
||||
if (!req.body) return new Uint8Array();
|
||||
const reader = req.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > max) {
|
||||
await reader.cancel();
|
||||
throw tooLarge();
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
const out = new Uint8Array(total);
|
||||
let at = 0;
|
||||
for (const c of chunks) {
|
||||
out.set(c, at);
|
||||
at += c.byteLength;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Fixed-window-ish limiter: at most `limit` hits per `windowMs` per key. */
|
||||
export function rateLimiter(limit: number, windowMs: number): (key: string) => boolean {
|
||||
const hits = new Map<string, number[]>();
|
||||
return (key) => {
|
||||
const now = Date.now();
|
||||
if (hits.size > 10_000) for (const [k, v] of hits) if (v.every((t) => now - t >= windowMs)) hits.delete(k);
|
||||
const recent = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
|
||||
if (recent.length >= limit) {
|
||||
hits.set(key, recent);
|
||||
return false;
|
||||
}
|
||||
recent.push(now);
|
||||
hits.set(key, recent);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
const SCOPES: TokenScope[] = ["read", "write", "ingest"];
|
||||
|
||||
/** Resolve the caller for `mode`. Returns the user and the session token used, if any. */
|
||||
async function authenticate(req: Request, mode: AuthMode, db: Db): Promise<{ user: User | null; sessionToken: string | null }> {
|
||||
if (mode === "none") return { user: null, sessionToken: null };
|
||||
const authz = req.headers.get("authorization");
|
||||
const bearer = authz?.startsWith("Bearer ") ? authz.slice(7).trim() : null;
|
||||
const [kind, scopeName] = mode.split(":") as ["session" | "token" | "any", TokenScope | undefined];
|
||||
|
||||
if (kind !== "session" && bearer) {
|
||||
const scope = scopeName!;
|
||||
const user = await verifyAppToken(db, bearer, scope);
|
||||
if (user) return { user, sessionToken: null };
|
||||
for (const other of SCOPES) {
|
||||
if (other !== scope && (await verifyAppToken(db, bearer, other))) {
|
||||
throw new HttpError(403, "forbidden", `This app token lacks the '${scope}' scope.`);
|
||||
}
|
||||
}
|
||||
throw new HttpError(401, "unauthenticated", "Invalid or revoked app token.");
|
||||
}
|
||||
if (kind === "token") throw new HttpError(401, "unauthenticated", "Send an app token: Authorization: Bearer <token>.");
|
||||
|
||||
const token = readCookie(req, SESSION_COOKIE);
|
||||
const user = token ? await getSessionUser(db, token) : null;
|
||||
if (!user) throw new HttpError(401, "unauthenticated", "Sign in first.");
|
||||
// CSRF: browsers cannot set this header cross-site without a CORS preflight, which we never grant.
|
||||
if (req.method !== "GET" && req.method !== "HEAD" && req.headers.get(CSRF_HEADER) !== CSRF_VALUE) {
|
||||
throw new HttpError(403, "forbidden", `Cookie-authenticated requests must send the header X-Requested-With: ${CSRF_VALUE}.`);
|
||||
}
|
||||
return { user, sessionToken: token };
|
||||
}
|
||||
|
||||
interface Compiled {
|
||||
def: RouteDef;
|
||||
segments: string[];
|
||||
}
|
||||
|
||||
function matchSegments(pattern: string[], parts: string[]): Record<string, string> | null {
|
||||
if (pattern.length !== parts.length) return null;
|
||||
const params: Record<string, string> = {};
|
||||
for (let i = 0; i < pattern.length; i++) {
|
||||
const p = pattern[i]!;
|
||||
const v = parts[i]!;
|
||||
if (p.startsWith(":")) {
|
||||
if (v === "") return null;
|
||||
try {
|
||||
params[p.slice(1)] = decodeURIComponent(v);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} else if (p !== v) return null;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/** Build the `/api/v1/*` request handler over a route table. */
|
||||
export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/"): (req: Request, ip: string) => Promise<Response> {
|
||||
const compiled: Compiled[] = routes.map((def) => ({ def, segments: def.path.split("/") }));
|
||||
const limit = rateLimiter(10, 60_000);
|
||||
|
||||
return async (req, ip) => {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const parts = url.pathname.slice(prefix.length).replace(/\/$/, "").split("/");
|
||||
let found: { def: RouteDef; params: Record<string, string> } | null = null;
|
||||
for (const c of compiled) {
|
||||
if (c.def.method !== req.method) continue;
|
||||
const params = matchSegments(c.segments, parts);
|
||||
if (params) {
|
||||
found = { def: c.def, params };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) return errorResponse(404, "not_found", "No such endpoint.");
|
||||
const { def, params } = found;
|
||||
|
||||
if (def.rateLimited && !limit(`${def.path}|${ip}`)) {
|
||||
return errorResponse(429, "rate_limited", "Too many attempts. Wait a minute and try again.", { "retry-after": "60" });
|
||||
}
|
||||
const { user, sessionToken } = await authenticate(req, def.auth, deps.db);
|
||||
if (def.admin && user?.role !== "admin") throw new HttpError(403, "forbidden", "Only an admin can do that.");
|
||||
|
||||
let body: unknown;
|
||||
let form: FormData | undefined;
|
||||
if (def.multipart) {
|
||||
const type = req.headers.get("content-type") ?? "";
|
||||
if (!type.startsWith("multipart/form-data")) throw new HttpError(400, "validation", "body: expected multipart/form-data");
|
||||
const bytes = await readLimited(req, MAX_UPLOAD_BYTES);
|
||||
try {
|
||||
form = await new Response(bytes, { headers: { "content-type": type } }).formData();
|
||||
} catch {
|
||||
throw new HttpError(400, "validation", "body: malformed multipart form data");
|
||||
}
|
||||
} else if (def.body) {
|
||||
const bytes = await readLimited(req, MAX_JSON_BYTES);
|
||||
let raw: unknown;
|
||||
if (bytes.byteLength > 0) {
|
||||
try {
|
||||
raw = JSON.parse(new TextDecoder().decode(bytes));
|
||||
} catch {
|
||||
throw new HttpError(400, "validation", "body: invalid JSON");
|
||||
}
|
||||
}
|
||||
body = def.body.parse(raw);
|
||||
}
|
||||
const query = def.query ? def.query.parse(Object.fromEntries(url.searchParams)) : undefined;
|
||||
|
||||
const ctx: Ctx<unknown, unknown> = {
|
||||
req,
|
||||
url,
|
||||
params,
|
||||
body,
|
||||
query,
|
||||
get user(): User {
|
||||
if (!user) throw new Error(`route ${def.method} ${def.path} has no authenticated user`);
|
||||
return user;
|
||||
},
|
||||
sessionToken,
|
||||
get form(): FormData {
|
||||
if (!form) throw new Error(`route ${def.method} ${def.path} is not multipart`);
|
||||
return form;
|
||||
},
|
||||
deps,
|
||||
ip,
|
||||
};
|
||||
const out = await def.handler(ctx);
|
||||
return out instanceof Response ? out : json(out);
|
||||
} catch (e) {
|
||||
return toErrorResponse(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { z } from "zod";
|
||||
import type { RouteDef } from "./http.ts";
|
||||
|
||||
type JsonSchema = Record<string, unknown>;
|
||||
|
||||
const toSchema = (s: z.ZodType): JsonSchema => {
|
||||
const { $schema: _, ...rest } = z.toJSONSchema(s, { io: "input", unrepresentable: "any" }) as JsonSchema;
|
||||
return rest;
|
||||
};
|
||||
|
||||
const SECURITY: Record<RouteDef["auth"], { [k: string]: string[] }[]> = {
|
||||
none: [],
|
||||
session: [{ session: [] }],
|
||||
"token:read": [{ appToken: ["read"] }],
|
||||
"token:write": [{ appToken: ["write"] }],
|
||||
"any:read": [{ session: [] }, { appToken: ["read"] }],
|
||||
"any:write": [{ session: [] }, { appToken: ["write"] }],
|
||||
};
|
||||
|
||||
/** OpenAPI 3.1 document generated from the route table (paths, summaries, request schemas). */
|
||||
export function buildOpenApi(routes: RouteDef[], prefix = "/api/v1"): JsonSchema {
|
||||
const paths: Record<string, Record<string, JsonSchema>> = {};
|
||||
for (const r of routes) {
|
||||
const path = `${prefix}/${r.path.replace(/:(\w+)/g, "{$1}")}`;
|
||||
const parameters: JsonSchema[] = [...r.path.matchAll(/:(\w+)/g)].map((m) => ({
|
||||
name: m[1],
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
}));
|
||||
if (r.query) {
|
||||
const q = toSchema(r.query) as { properties?: Record<string, JsonSchema>; required?: string[] };
|
||||
for (const [name, schema] of Object.entries(q.properties ?? {})) {
|
||||
parameters.push({ name, in: "query", required: q.required?.includes(name) ?? false, schema });
|
||||
}
|
||||
}
|
||||
const op: JsonSchema = {
|
||||
operationId: `${r.method.toLowerCase()}_${r.path.replace(/[^\w]+/g, "_")}`,
|
||||
summary: r.summary,
|
||||
security: SECURITY[r.auth],
|
||||
responses: {
|
||||
default: {
|
||||
description: "Success, or an error: `{ error: { code, message } }`.",
|
||||
},
|
||||
},
|
||||
};
|
||||
if (parameters.length > 0) op.parameters = parameters;
|
||||
if (r.body) op.requestBody = { required: !r.body.safeParse(undefined).success, content: { "application/json": { schema: toSchema(r.body) } } };
|
||||
if (r.multipart) op.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object" } } } };
|
||||
(paths[path] ??= {})[r.method.toLowerCase()] = op;
|
||||
}
|
||||
return {
|
||||
openapi: "3.1.0",
|
||||
info: { title: "STTracker API", version: "1" },
|
||||
servers: [{ url: "/" }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
session: { type: "apiKey", in: "cookie", name: "stt_session", description: "Non-GET requests also need X-Requested-With: sttracker." },
|
||||
appToken: { type: "http", scheme: "bearer", description: "App token with scopes read, write or ingest." },
|
||||
},
|
||||
},
|
||||
paths,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { listAttachmentLinks, readAttachment, requireLinkTarget, storeAttachment, unlinkAttachment } from "@sttracker/core/attachments";
|
||||
import { NotFoundError } from "@sttracker/core/errors";
|
||||
import type { AttachmentEntity } from "@sttracker/db/schema";
|
||||
import { z } from "zod";
|
||||
import { json, noContent, route } from "../http.ts";
|
||||
import { formFile } from "./form.ts";
|
||||
|
||||
const fields = z.object({
|
||||
entity: z.enum(["entry", "engagement", "copy", "list", "work"]),
|
||||
entityId: z.string().min(1),
|
||||
caption: z.string().max(10_000).optional(),
|
||||
});
|
||||
|
||||
/** A filename safe inside a quoted Content-Disposition value. */
|
||||
export function safeFilename(name: string): string {
|
||||
const cleaned = name.replace(/[^\w.\- ]/g, "_").slice(0, 200).trim();
|
||||
return cleaned === "" ? "file" : cleaned;
|
||||
}
|
||||
|
||||
export const attachmentRoutes = [
|
||||
route({
|
||||
method: "POST",
|
||||
path: "attachments",
|
||||
summary: "Upload a file (multipart: file, entity, entityId, caption) and link it to one of your records.",
|
||||
auth: "any:write",
|
||||
multipart: true,
|
||||
handler: async ({ deps, user, form }) => {
|
||||
const file = formFile(form);
|
||||
const f = fields.parse({
|
||||
entity: form.get("entity") ?? undefined,
|
||||
entityId: form.get("entityId") ?? undefined,
|
||||
caption: form.get("caption") ?? undefined,
|
||||
});
|
||||
await requireLinkTarget(deps.db, user.id, f.entity as AttachmentEntity, f.entityId);
|
||||
const att = await storeAttachment(
|
||||
deps.db,
|
||||
deps.dataDir,
|
||||
user.id,
|
||||
{ bytes: new Uint8Array(await file.arrayBuffer()), mime: file.type || "application/octet-stream", filename: file.name, caption: f.caption },
|
||||
{ entity: f.entity as AttachmentEntity, entityId: f.entityId },
|
||||
);
|
||||
return json({ ...att, links: await listAttachmentLinks(deps.db, user.id, att.id) }, 201);
|
||||
},
|
||||
}),
|
||||
route({
|
||||
method: "GET",
|
||||
path: "attachments/:id",
|
||||
summary: "Download an attachment's file.",
|
||||
auth: "any:read",
|
||||
handler: async ({ deps, user, params }) => {
|
||||
const a = await readAttachment(deps.db, deps.dataDir, user.id, params.id!);
|
||||
if (!a || !(await a.file.exists())) throw new NotFoundError("attachment");
|
||||
return new Response(a.file, {
|
||||
headers: {
|
||||
"content-type": a.mime,
|
||||
"x-content-type-options": "nosniff",
|
||||
"content-disposition": `inline; filename="${safeFilename(a.filename)}"`,
|
||||
"content-security-policy": "default-src 'none'; sandbox",
|
||||
"cache-control": "private, max-age=3600",
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
route({
|
||||
method: "DELETE",
|
||||
path: "attachments/:id/links/:linkId",
|
||||
summary: "Unlink an attachment; it is deleted when no links remain.",
|
||||
auth: "any:write",
|
||||
handler: async ({ deps, user, params }) => {
|
||||
const link = (await listAttachmentLinks(deps.db, user.id, params.id!)).find((l) => l.id === params.linkId);
|
||||
if (!link) throw new NotFoundError("attachment link");
|
||||
await unlinkAttachment(deps.db, deps.dataDir, user.id, params.id!, link.entity, link.entityId);
|
||||
return noContent();
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createAppToken, createInvite, listAppTokens, login, logout, registerUser, revokeAppToken } from "@sttracker/core/auth";
|
||||
import { z } from "zod";
|
||||
import { clearSessionCookie, json, noContent, route, sessionCookie } from "../http.ts";
|
||||
|
||||
const credentials = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
password: z.string().min(8).max(1024),
|
||||
});
|
||||
|
||||
export const authRoutes = [
|
||||
route({
|
||||
method: "POST",
|
||||
path: "auth/register",
|
||||
summary: "Create an account. The first user becomes admin; later ones need an invite code.",
|
||||
auth: "none",
|
||||
rateLimited: true,
|
||||
body: credentials.extend({ displayName: z.string().min(1).max(100).optional(), inviteCode: z.string().max(200).optional() }).strict(),
|
||||
handler: async ({ deps, body }) => json(await registerUser(deps.db, body), 201),
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "auth/login",
|
||||
summary: "Sign in; sets the stt_session cookie.",
|
||||
auth: "none",
|
||||
rateLimited: true,
|
||||
body: z.object({ username: z.string().max(64), password: z.string().max(1024) }).strict(),
|
||||
handler: async ({ deps, body }) => {
|
||||
const { user, sessionToken } = await login(deps.db, body.username, body.password);
|
||||
return json(user, 200, { "set-cookie": sessionCookie(sessionToken, deps.publicUrl) });
|
||||
},
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "auth/logout",
|
||||
summary: "End the current session and clear the cookie.",
|
||||
auth: "session",
|
||||
handler: async ({ deps, sessionToken }) => {
|
||||
if (sessionToken) await logout(deps.db, sessionToken);
|
||||
return new Response(null, { status: 204, headers: { "set-cookie": clearSessionCookie(deps.publicUrl) } });
|
||||
},
|
||||
}),
|
||||
route({ method: "GET", path: "me", summary: "The signed-in user.", auth: "any:read", handler: async ({ user }) => user }),
|
||||
route({
|
||||
method: "GET",
|
||||
path: "me/tokens",
|
||||
summary: "List your app tokens (metadata only).",
|
||||
auth: "session",
|
||||
handler: async ({ deps, user }) => listAppTokens(deps.db, user.id),
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "me/tokens",
|
||||
summary: "Create an app token. The token is returned once.",
|
||||
auth: "session",
|
||||
body: z.object({ name: z.string().min(1).max(100), scopes: z.array(z.enum(["read", "write", "ingest"])).min(1) }).strict(),
|
||||
handler: async ({ deps, user, body }) => json(await createAppToken(deps.db, user.id, body.name, [...new Set(body.scopes)]), 201),
|
||||
}),
|
||||
route({
|
||||
method: "DELETE",
|
||||
path: "me/tokens/:id",
|
||||
summary: "Revoke an app token.",
|
||||
auth: "session",
|
||||
handler: async ({ deps, user, params }) => {
|
||||
await revokeAppToken(deps.db, user.id, params.id!);
|
||||
return noContent();
|
||||
},
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "admin/invites",
|
||||
summary: "Create an invite code (admin). The code is returned once.",
|
||||
auth: "session",
|
||||
body: z.object({ ttlDays: z.number().int().min(1).max(365).optional() }).strict().optional(),
|
||||
handler: async ({ deps, user, body }) => json({ code: await createInvite(deps.db, user.id, body?.ttlDays) }, 201),
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,31 @@
|
||||
import { listChanges, resolveChange } from "@sttracker/core/sync/changes";
|
||||
import { z } from "zod";
|
||||
import { noContent, route } from "../http.ts";
|
||||
import { qInt } from "./schemas.ts";
|
||||
|
||||
export const changeRoutes = [
|
||||
route({
|
||||
method: "GET",
|
||||
path: "changes",
|
||||
summary: "The Changes inbox (pending by default; status=all for everything).",
|
||||
auth: "any:read",
|
||||
query: z.object({
|
||||
status: z.enum(["pending", "all"]).optional(),
|
||||
connectionId: z.string().optional(),
|
||||
limit: qInt.optional(),
|
||||
cursor: z.string().optional(),
|
||||
}),
|
||||
handler: async ({ deps, user, query }) => listChanges(deps.db, user.id, query),
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "changes/:id/resolve",
|
||||
summary: "Resolve a pending change: accept, ignore or keep_mine.",
|
||||
auth: "any:write",
|
||||
body: z.object({ action: z.enum(["accept", "ignore", "keep_mine"]) }).strict(),
|
||||
handler: async ({ deps, user, params, body }) => {
|
||||
await resolveChange(deps.db, user.id, params.id!, body.action);
|
||||
return noContent();
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createCopy, listCopies, softDeleteCopy, updateCopy } from "@sttracker/core/copies";
|
||||
import { z } from "zod";
|
||||
import { json, noContent, route } from "../http.ts";
|
||||
import { customFields, partialDate, text } from "./schemas.ts";
|
||||
|
||||
const copyPatch = z
|
||||
.object({
|
||||
workId: z.string().min(1).nullable(),
|
||||
titleHint: z.string().max(1000).nullable(),
|
||||
format: z.string().max(200).nullable(),
|
||||
acquiredFrom: z.string().max(500).nullable(),
|
||||
acquiredAt: partialDate.nullable(),
|
||||
priceCents: z.number().int().nullable(),
|
||||
currency: z.string().length(3).nullable(),
|
||||
notes: text().nullable(),
|
||||
customFields,
|
||||
sourceRef: z.string().max(500).nullable(),
|
||||
})
|
||||
.partial()
|
||||
.strict();
|
||||
type CopyArg = Parameters<typeof updateCopy>[3];
|
||||
|
||||
export const copyRoutes = [
|
||||
route({
|
||||
method: "GET",
|
||||
path: "copies",
|
||||
summary: "Your owned copies (optionally of one work).",
|
||||
auth: "any:read",
|
||||
query: z.object({ workId: z.string().optional() }),
|
||||
handler: async ({ deps, user, query }) => listCopies(deps.db, user.id, query),
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "copies",
|
||||
summary: "Record an owned copy.",
|
||||
auth: "any:write",
|
||||
body: copyPatch,
|
||||
handler: async ({ deps, user, body }) => json(await createCopy(deps.db, user.id, body as CopyArg), 201),
|
||||
}),
|
||||
route({
|
||||
method: "PATCH",
|
||||
path: "copies/:id",
|
||||
summary: "Edit an owned copy.",
|
||||
auth: "any:write",
|
||||
body: copyPatch,
|
||||
handler: async ({ deps, user, params, body }) => updateCopy(deps.db, user.id, params.id!, body as CopyArg),
|
||||
}),
|
||||
route({
|
||||
method: "DELETE",
|
||||
path: "copies/:id",
|
||||
summary: "Soft-delete an owned copy.",
|
||||
auth: "any:write",
|
||||
handler: async ({ deps, user, params }) => {
|
||||
await softDeleteCopy(deps.db, user.id, params.id!);
|
||||
return noContent();
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,32 @@
|
||||
import { CUSTOM_FIELD_TYPES, defineCustomField, listCustomFields } from "@sttracker/core/custom-fields";
|
||||
import { z } from "zod";
|
||||
import { json, route } from "../http.ts";
|
||||
|
||||
const level = z.enum(["entry", "engagement", "copy"]);
|
||||
|
||||
export const customFieldRoutes = [
|
||||
route({
|
||||
method: "GET",
|
||||
path: "custom-fields",
|
||||
summary: "Your custom field definitions.",
|
||||
auth: "any:read",
|
||||
query: z.object({ mediaType: z.string().optional(), level: level.optional() }),
|
||||
handler: async ({ deps, user, query }) => listCustomFields(deps.db, user.id, query.mediaType, query.level),
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "custom-fields",
|
||||
summary: "Define a custom field.",
|
||||
auth: "any:write",
|
||||
body: z
|
||||
.object({
|
||||
name: z.string().min(1).max(200),
|
||||
type: z.enum(CUSTOM_FIELD_TYPES),
|
||||
options: z.array(z.string().max(200)).max(500).optional(),
|
||||
appliesTo: z.union([z.literal("all"), z.array(z.string().max(50))]).optional(),
|
||||
level: level.optional(),
|
||||
})
|
||||
.strict(),
|
||||
handler: async ({ deps, user, body }) => json(await defineCustomField(deps.db, user.id, body), 201),
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,55 @@
|
||||
import { addEngagement, listEngagements, softDeleteEngagement, updateEngagement } from "@sttracker/core/engagements";
|
||||
import { z } from "zod";
|
||||
import { json, noContent, route } from "../http.ts";
|
||||
import { customFields, partialDate, rating, text } from "./schemas.ts";
|
||||
|
||||
const engagementPatch = z
|
||||
.object({
|
||||
startedAt: partialDate.nullable(),
|
||||
finishedAt: partialDate.nullable(),
|
||||
progress: z.record(z.string(), z.union([z.number(), z.string().max(200)])),
|
||||
platform: z.string().max(200).nullable(),
|
||||
rating,
|
||||
notes: text().nullable(),
|
||||
customFields,
|
||||
})
|
||||
.partial()
|
||||
.strict();
|
||||
|
||||
export const engagementRoutes = [
|
||||
route({
|
||||
method: "GET",
|
||||
path: "entries/:id/engagements",
|
||||
summary: "Reads, watches or plays of an entry.",
|
||||
auth: "any:read",
|
||||
handler: async ({ deps, user, params }) => listEngagements(deps.db, user.id, params.id!),
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "entries/:id/engagements",
|
||||
summary: "Log a read, watch or play.",
|
||||
auth: "any:write",
|
||||
body: engagementPatch,
|
||||
handler: async ({ deps, user, params, body }) =>
|
||||
json(await addEngagement(deps.db, user.id, params.id!, body as Parameters<typeof addEngagement>[3], "user"), 201),
|
||||
}),
|
||||
route({
|
||||
method: "PATCH",
|
||||
path: "engagements/:id",
|
||||
summary: "Edit an engagement.",
|
||||
auth: "any:write",
|
||||
body: engagementPatch,
|
||||
handler: async ({ deps, user, params, body }) =>
|
||||
updateEngagement(deps.db, user.id, params.id!, body as Parameters<typeof updateEngagement>[3], "user"),
|
||||
}),
|
||||
route({
|
||||
method: "DELETE",
|
||||
path: "engagements/:id",
|
||||
summary: "Soft-delete an engagement.",
|
||||
auth: "any:write",
|
||||
handler: async ({ deps, user, params }) => {
|
||||
await softDeleteEngagement(deps.db, user.id, params.id!, "user");
|
||||
return noContent();
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,107 @@
|
||||
import { getEntry, listEntries, moveEntryToWork, quickAdd, softDeleteEntry, upsertEntry } from "@sttracker/core/entries";
|
||||
import { getHistory, restoreVersion } from "@sttracker/core/history";
|
||||
import { z } from "zod";
|
||||
import { json, noContent, route } from "../http.ts";
|
||||
import { customFields, mediaType, qBool, qInt, rating, status, text, visibility } from "./schemas.ts";
|
||||
|
||||
const entryPatch = z
|
||||
.object({
|
||||
status,
|
||||
rating,
|
||||
review: text().nullable(),
|
||||
privateNotes: text().nullable(),
|
||||
favorite: z.boolean(),
|
||||
tags: z.array(z.string().max(100)).max(200),
|
||||
customFields,
|
||||
visibility,
|
||||
overrides: z.object({ title: z.string().max(1000), coverUrl: z.string().max(2000), year: z.number().int().nullable() }).partial().strict(),
|
||||
mediaTypeOverride: mediaType.nullable(),
|
||||
})
|
||||
.partial()
|
||||
.strict();
|
||||
|
||||
export const entryRoutes = [
|
||||
route({
|
||||
method: "GET",
|
||||
path: "entries",
|
||||
summary: "List your library entries (filter, sort, cursor pagination).",
|
||||
auth: "any:read",
|
||||
query: z.object({
|
||||
mediaType: z.string().optional(),
|
||||
status: status.optional(),
|
||||
tag: z.string().optional(),
|
||||
draft: qBool.optional(),
|
||||
q: z.string().max(500).optional(),
|
||||
parentId: z.string().optional(),
|
||||
sort: z.enum(["updated", "title", "rating"]).optional(),
|
||||
limit: qInt.optional(),
|
||||
cursor: z.string().optional(),
|
||||
}),
|
||||
handler: async ({ deps, user, query }) => listEntries(deps.db, user.id, query as Parameters<typeof listEntries>[2]),
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "entries/quick",
|
||||
summary: "Quick-add an entry by title (creates a private draft work).",
|
||||
auth: "any:write",
|
||||
body: z.object({ title: z.string().min(1).max(1000), mediaType: mediaType.optional() }).strict(),
|
||||
handler: async ({ deps, user, body }) => json(await quickAdd(deps.db, user.id, body), 201),
|
||||
}),
|
||||
route({
|
||||
method: "GET",
|
||||
path: "entries/:id",
|
||||
summary: "One entry with its work.",
|
||||
auth: "any:read",
|
||||
handler: async ({ deps, user, params }) => getEntry(deps.db, user.id, params.id!),
|
||||
}),
|
||||
route({
|
||||
method: "PATCH",
|
||||
path: "entries/:id",
|
||||
summary: "Edit an entry. Edited fields become user-owned (syncs stop overwriting them).",
|
||||
auth: "any:write",
|
||||
body: entryPatch,
|
||||
handler: async ({ deps, user, params, body }) => {
|
||||
const cur = await getEntry(deps.db, user.id, params.id!);
|
||||
await upsertEntry(deps.db, user.id, cur.workId, body as Parameters<typeof upsertEntry>[3], "user");
|
||||
return getEntry(deps.db, user.id, params.id!);
|
||||
},
|
||||
}),
|
||||
route({
|
||||
method: "DELETE",
|
||||
path: "entries/:id",
|
||||
summary: "Soft-delete an entry.",
|
||||
auth: "any:write",
|
||||
handler: async ({ deps, user, params }) => {
|
||||
await softDeleteEntry(deps.db, user.id, params.id!, "user");
|
||||
return noContent();
|
||||
},
|
||||
}),
|
||||
route({
|
||||
method: "GET",
|
||||
path: "entries/:id/history",
|
||||
summary: "Change history of an entry, newest first.",
|
||||
auth: "any:read",
|
||||
handler: async ({ deps, user, params }) => getHistory(deps.db, user.id, "entry", params.id!),
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "history/:id/restore",
|
||||
summary: "Restore the values a history row replaced.",
|
||||
auth: "any:write",
|
||||
handler: async ({ deps, user, params }) => {
|
||||
await restoreVersion(deps.db, user.id, params.id!);
|
||||
return noContent();
|
||||
},
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "entries/:id/move",
|
||||
summary: "Move an entry to another work (for you only).",
|
||||
auth: "any:write",
|
||||
body: z.object({ workId: z.string().min(1) }).strict(),
|
||||
handler: async ({ deps, user, params, body }) => {
|
||||
const moved = await moveEntryToWork(deps.db, user.id, params.id!, body.workId, "user");
|
||||
return getEntry(deps.db, user.id, moved.id);
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NotFoundError } from "@sttracker/core/errors";
|
||||
import { exportUserBundle, exportUserCsv, importUserBundle } from "@sttracker/core/export";
|
||||
import { z } from "zod";
|
||||
import { route } from "../http.ts";
|
||||
import { formFile } from "./form.ts";
|
||||
|
||||
const stamp = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
export const exportRoutes = [
|
||||
route({
|
||||
method: "GET",
|
||||
path: "export/bundle",
|
||||
summary: "Download your data as an sttracker-export/v1 zip bundle.",
|
||||
auth: "any:read",
|
||||
handler: async ({ deps, user }) =>
|
||||
new Response((await exportUserBundle(deps.db, deps.dataDir, user.id)) as Uint8Array<ArrayBuffer>, {
|
||||
headers: {
|
||||
"content-type": "application/zip",
|
||||
"content-disposition": `attachment; filename="sttracker-${user.username}-${stamp()}.zip"`,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
route({
|
||||
method: "GET",
|
||||
path: "export/csv",
|
||||
summary: "Download one CSV file of your data (e.g. file=entries.csv).",
|
||||
auth: "any:read",
|
||||
query: z.object({ file: z.string().regex(/^[a-z_]+\.csv$/) }),
|
||||
handler: async ({ deps, user, query }) => {
|
||||
const files = await exportUserCsv(deps.db, user.id);
|
||||
const body = files[query.file];
|
||||
if (body === undefined) throw new NotFoundError("csv file");
|
||||
return new Response(body, {
|
||||
headers: { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="${query.file}"` },
|
||||
});
|
||||
},
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "import/bundle",
|
||||
summary: "Restore an export bundle (multipart: file) into your empty account.",
|
||||
auth: "session",
|
||||
multipart: true,
|
||||
handler: async ({ deps, user, form }) => {
|
||||
const file = formFile(form);
|
||||
return importUserBundle(deps.db, deps.dataDir, user.id, new Uint8Array(await file.arrayBuffer()));
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,8 @@
|
||||
import { HttpError } from "../http.ts";
|
||||
|
||||
/** The `file` part of a multipart body, or a 400. */
|
||||
export function formFile(form: FormData, field = "file"): File {
|
||||
const f = form.get(field);
|
||||
if (!(f instanceof File)) throw new HttpError(400, "validation", `${field}: a file is required`);
|
||||
return f;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { addListItem, createList, getList, listLists, moveListItem, removeListItem, softDeleteList, updateList } from "@sttracker/core/lists";
|
||||
import { z } from "zod";
|
||||
import { json, noContent, route } from "../http.ts";
|
||||
import { rating, text, visibility } from "./schemas.ts";
|
||||
|
||||
const kind = z.enum(["list", "grouping", "thread"]);
|
||||
const listPatch = z
|
||||
.object({ name: z.string().min(1).max(500), description: text(), kind, visibility, rating, review: text().nullable() })
|
||||
.partial()
|
||||
.strict();
|
||||
type ListPatchArg = Parameters<typeof updateList>[3];
|
||||
|
||||
export const listRoutes = [
|
||||
route({
|
||||
method: "GET",
|
||||
path: "lists",
|
||||
summary: "Your lists.",
|
||||
auth: "any:read",
|
||||
query: z.object({ kind: kind.optional() }),
|
||||
handler: async ({ deps, user, query }) => listLists(deps.db, user.id, query),
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "lists",
|
||||
summary: "Create a list.",
|
||||
auth: "any:write",
|
||||
body: listPatch.required({ name: true }),
|
||||
handler: async ({ deps, user, body }) => json(await createList(deps.db, user.id, body as ListPatchArg & { name: string }), 201),
|
||||
}),
|
||||
route({
|
||||
method: "GET",
|
||||
path: "lists/:id",
|
||||
summary: "A list with its items.",
|
||||
auth: "any:read",
|
||||
handler: async ({ deps, user, params }) => getList(deps.db, user.id, params.id!),
|
||||
}),
|
||||
route({
|
||||
method: "PATCH",
|
||||
path: "lists/:id",
|
||||
summary: "Edit a list.",
|
||||
auth: "any:write",
|
||||
body: listPatch,
|
||||
handler: async ({ deps, user, params, body }) => updateList(deps.db, user.id, params.id!, body as ListPatchArg),
|
||||
}),
|
||||
route({
|
||||
method: "DELETE",
|
||||
path: "lists/:id",
|
||||
summary: "Soft-delete a list.",
|
||||
auth: "any:write",
|
||||
handler: async ({ deps, user, params }) => {
|
||||
await softDeleteList(deps.db, user.id, params.id!);
|
||||
return noContent();
|
||||
},
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "lists/:id/items",
|
||||
summary: "Add a work to a list.",
|
||||
auth: "any:write",
|
||||
body: z.object({ workId: z.string().min(1), note: text(10_000).nullable().optional(), position: z.number().int().min(0).optional() }).strict(),
|
||||
handler: async ({ deps, user, params, body }) => json(await addListItem(deps.db, user.id, params.id!, body), 201),
|
||||
}),
|
||||
route({
|
||||
method: "PATCH",
|
||||
path: "list-items/:id",
|
||||
summary: "Move a list item to a new 0-based position.",
|
||||
auth: "any:write",
|
||||
body: z.object({ position: z.number().int().min(0) }).strict(),
|
||||
handler: async ({ deps, user, params, body }) => {
|
||||
await moveListItem(deps.db, user.id, params.id!, body.position);
|
||||
return noContent();
|
||||
},
|
||||
}),
|
||||
route({
|
||||
method: "DELETE",
|
||||
path: "list-items/:id",
|
||||
summary: "Remove an item from a list.",
|
||||
auth: "any:write",
|
||||
handler: async ({ deps, user, params }) => {
|
||||
await removeListItem(deps.db, user.id, params.id!);
|
||||
return noContent();
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,40 @@
|
||||
import { route, type RouteDef } from "../http.ts";
|
||||
import { buildOpenApi } from "../openapi.ts";
|
||||
|
||||
/** Routes that describe the API and instance. `table` returns the full route table (for OpenAPI). */
|
||||
export function metaRoutes(table: () => RouteDef[]): RouteDef[] {
|
||||
let doc: unknown;
|
||||
return [
|
||||
route({
|
||||
method: "GET",
|
||||
path: "openapi.json",
|
||||
summary: "This API's OpenAPI document.",
|
||||
auth: "none",
|
||||
handler: async () => (doc ??= buildOpenApi(table())),
|
||||
}),
|
||||
route({
|
||||
method: "GET",
|
||||
path: "about",
|
||||
summary: "Instance information and data-source attributions.",
|
||||
auth: "none",
|
||||
handler: async ({ deps }) => ({
|
||||
name: "STTracker",
|
||||
attributions: deps.registry
|
||||
.list()
|
||||
.filter((m) => m.attribution)
|
||||
.map((m) => ({ pluginId: m.id, name: m.name, attribution: m.attribution })),
|
||||
}),
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "admin/plugins/reload",
|
||||
summary: "Rescan the plugin directories (admin).",
|
||||
auth: "session",
|
||||
admin: true,
|
||||
handler: async ({ deps }) => {
|
||||
await deps.registry.reload();
|
||||
return deps.registry.list();
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MEDIA_TYPES, STATUSES, VISIBILITIES } from "@sttracker/core/entries";
|
||||
import { z } from "zod";
|
||||
|
||||
/** Shared request schemas. Core functions re-validate; these give early, documented 400s. */
|
||||
export const rating = z.number().int().min(0).max(100).nullable();
|
||||
export const mediaType = z.enum(MEDIA_TYPES);
|
||||
export const status = z.enum(STATUSES as [string, ...string[]]);
|
||||
export const visibility = z.enum(VISIBILITIES as [string, ...string[]]);
|
||||
export const partialDate = z.object({
|
||||
value: z.string().max(40),
|
||||
precision: z.enum(["day", "month", "year", "unknown"]),
|
||||
});
|
||||
export const customFields = z.record(z.string(), z.unknown());
|
||||
export const text = (max = 100_000) => z.string().max(max);
|
||||
|
||||
/** Query-string boolean: "true" / "false". */
|
||||
export const qBool = z.enum(["true", "false"]).transform((v) => v === "true");
|
||||
/** Query-string positive integer. */
|
||||
export const qInt = z.coerce.number().int().min(1).max(1000);
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
createConnection,
|
||||
deleteConnection,
|
||||
devicePoll,
|
||||
deviceStart,
|
||||
getConnection,
|
||||
isDeletedConnection,
|
||||
listConnections,
|
||||
listRuns,
|
||||
updateConnection,
|
||||
} from "@sttracker/core/connections";
|
||||
import { ValidationError } from "@sttracker/core/errors";
|
||||
import { enqueue } from "@sttracker/core/jobs";
|
||||
import type { PluginRegistry } from "@sttracker/core/registry";
|
||||
import { z } from "zod";
|
||||
import { HttpError, json, noContent, route } from "../http.ts";
|
||||
import { formFile } from "./form.ts";
|
||||
import { qInt } from "./schemas.ts";
|
||||
|
||||
const ALREADY_RUNNING = "A sync for this source is already queued or running.";
|
||||
|
||||
function pluginOf(registry: PluginRegistry, pluginId: string) {
|
||||
const plugin = registry.get(pluginId);
|
||||
if (!plugin) throw new ValidationError("pluginId", "not_installed");
|
||||
return plugin;
|
||||
}
|
||||
|
||||
async function enqueueSync(db: Parameters<typeof enqueue>[0], connectionId: string, payload: Record<string, unknown>): Promise<Response> {
|
||||
const jobId = await enqueue(db, "sync", { connectionId, ...payload }, { lockKey: `sync:${connectionId}` });
|
||||
if (jobId === null) throw new HttpError(409, "conflict", ALREADY_RUNNING);
|
||||
return json({ jobId }, 202);
|
||||
}
|
||||
|
||||
const schedule = z.number().int().min(1).nullable();
|
||||
const config = z.record(z.string(), z.unknown());
|
||||
const creds = z.record(z.string(), z.string());
|
||||
|
||||
export const sourceRoutes = [
|
||||
route({
|
||||
method: "GET",
|
||||
path: "plugins",
|
||||
summary: "Installed source plugins (manifests).",
|
||||
auth: "any:read",
|
||||
handler: async ({ deps }) => deps.registry.list(),
|
||||
}),
|
||||
route({
|
||||
method: "GET",
|
||||
path: "sources",
|
||||
summary: "Your source connections (never includes secrets).",
|
||||
auth: "any:read",
|
||||
handler: async ({ deps, user }) => (await listConnections(deps.db, user.id)).filter((c) => !isDeletedConnection(c)),
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "sources",
|
||||
summary: "Connect a source. Config fields typed secret are stored encrypted.",
|
||||
auth: "session",
|
||||
body: z
|
||||
.object({ pluginId: z.string().min(1), name: z.string().min(1).max(200), config: config.optional(), credentials: creds.optional(), scheduleMinutes: schedule.optional() })
|
||||
.strict(),
|
||||
handler: async ({ deps, user, body }) => {
|
||||
const plugin = pluginOf(deps.registry, body.pluginId);
|
||||
const id = await createConnection(deps.db, deps.secret, user.id, { ...body, config: body.config ?? {} }, plugin.manifest);
|
||||
return json(await getConnection(deps.db, user.id, id), 201);
|
||||
},
|
||||
}),
|
||||
route({
|
||||
method: "PATCH",
|
||||
path: "sources/:id",
|
||||
summary: "Edit a source connection. Omitted secrets are kept.",
|
||||
auth: "session",
|
||||
body: z
|
||||
.object({ name: z.string().min(1).max(200), config, credentials: creds, scheduleMinutes: schedule, status: z.enum(["active", "paused"]) })
|
||||
.partial()
|
||||
.strict(),
|
||||
handler: async ({ deps, user, params, body }) => {
|
||||
const conn = await getConnection(deps.db, user.id, params.id!);
|
||||
await updateConnection(deps.db, deps.secret, user.id, conn.id, body, pluginOf(deps.registry, conn.pluginId).manifest);
|
||||
return getConnection(deps.db, user.id, conn.id);
|
||||
},
|
||||
}),
|
||||
route({
|
||||
method: "DELETE",
|
||||
path: "sources/:id",
|
||||
summary: "Disconnect a source: pauses it and wipes its credentials. Imported data stays.",
|
||||
auth: "session",
|
||||
handler: async ({ deps, user, params }) => {
|
||||
await getConnection(deps.db, user.id, params.id!);
|
||||
await deleteConnection(deps.db, user.id, params.id!);
|
||||
return noContent();
|
||||
},
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "sources/:id/run",
|
||||
summary: "Queue a sync now. 409 if one is already queued or running.",
|
||||
auth: "any:write",
|
||||
body: z.object({ mode: z.enum(["full", "changes"]).optional() }).strict().optional(),
|
||||
handler: async ({ deps, user, params, body }) => {
|
||||
const conn = await getConnection(deps.db, user.id, params.id!);
|
||||
return enqueueSync(deps.db, conn.id, body?.mode ? { mode: body.mode } : {});
|
||||
},
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "sources/:id/import",
|
||||
summary: "Upload an export file (multipart: file) and queue a file-mode sync.",
|
||||
auth: "any:write",
|
||||
multipart: true,
|
||||
handler: async ({ deps, user, params, form }) => {
|
||||
const conn = await getConnection(deps.db, user.id, params.id!);
|
||||
if (!pluginOf(deps.registry, conn.pluginId).importFile) throw new ValidationError("pluginId", "cannot_import_files");
|
||||
const file = formFile(form);
|
||||
const base64 = Buffer.from(await file.arrayBuffer()).toString("base64");
|
||||
return enqueueSync(deps.db, conn.id, { file: { name: file.name, base64 } });
|
||||
},
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "sources/:id/device/start",
|
||||
summary: "Start a device-code sign-in with the source.",
|
||||
auth: "session",
|
||||
handler: async ({ deps, user, params }) => deviceStart(deps.db, deps.secret, deps.registry, user.id, params.id!),
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "sources/:id/device/poll",
|
||||
summary: "Poll a device-code sign-in: pending or connected.",
|
||||
auth: "session",
|
||||
body: z.object({ deviceCode: z.string().min(1).max(2000) }).strict(),
|
||||
handler: async ({ deps, user, params, body }) => ({
|
||||
status: await devicePoll(deps.db, deps.secret, deps.registry, user.id, params.id!, body.deviceCode),
|
||||
}),
|
||||
}),
|
||||
route({
|
||||
method: "GET",
|
||||
path: "sources/:id/runs",
|
||||
summary: "Recent sync runs of a source, newest first.",
|
||||
auth: "any:read",
|
||||
query: z.object({ limit: qInt.optional() }),
|
||||
handler: async ({ deps, user, params, query }) => listRuns(deps.db, user.id, params.id!, query.limit),
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,56 @@
|
||||
import { acceptSuggestion, listSuggestions, rejectSuggestion, resolveVisibleWork } from "@sttracker/core/entries";
|
||||
import { NotFoundError } from "@sttracker/core/errors";
|
||||
import { getWork, searchWorks } from "@sttracker/core/works";
|
||||
import { z } from "zod";
|
||||
import { noContent, route } from "../http.ts";
|
||||
import { mediaType, qInt } from "./schemas.ts";
|
||||
|
||||
export const workRoutes = [
|
||||
route({
|
||||
method: "GET",
|
||||
path: "works/search",
|
||||
summary: "Search works visible to you by title.",
|
||||
auth: "any:read",
|
||||
query: z.object({ q: z.string().min(1).max(500), mediaType: mediaType.optional(), limit: qInt.optional() }),
|
||||
handler: async ({ deps, user, query }) => searchWorks(deps.db, user.id, query.q, { mediaType: query.mediaType, limit: query.limit }),
|
||||
}),
|
||||
route({
|
||||
method: "GET",
|
||||
path: "works/:id",
|
||||
summary: "One work (follows merges and your aliases).",
|
||||
auth: "any:read",
|
||||
handler: async ({ deps, user, params }) => {
|
||||
const visible = await resolveVisibleWork(deps.db.k, user.id, params.id!);
|
||||
const work = await getWork(deps.db, visible.id);
|
||||
if (!work) throw new NotFoundError("work");
|
||||
return work;
|
||||
},
|
||||
}),
|
||||
route({
|
||||
method: "GET",
|
||||
path: "works/:id/suggestions",
|
||||
summary: "Pending match suggestions for a work.",
|
||||
auth: "any:read",
|
||||
handler: async ({ deps, user, params }) => listSuggestions(deps.db, user.id, params.id!),
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "suggestions/:id/accept",
|
||||
summary: "Accept a match suggestion (moves your entry to the candidate work).",
|
||||
auth: "any:write",
|
||||
handler: async ({ deps, user, params }) => {
|
||||
await acceptSuggestion(deps.db, user.id, params.id!);
|
||||
return noContent();
|
||||
},
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "suggestions/:id/reject",
|
||||
summary: "Reject a match suggestion.",
|
||||
auth: "any:write",
|
||||
handler: async ({ deps, user, params }) => {
|
||||
await rejectSuggestion(deps.db, user.id, params.id!);
|
||||
return noContent();
|
||||
},
|
||||
}),
|
||||
];
|
||||
Reference in New Issue
Block a user