kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { dirname } from "node:path";
2import { fileURLToPath, pathToFileURL } from "node:url";
3import { serve } from "@hono/node-server";
4import type { Session, User } from "better-auth/types";
5import { eq } from "drizzle-orm";
6import { migrate } from "drizzle-orm/node-postgres/migrator";
7import { Hono } from "hono";
8import { cors } from "hono/cors";
9import { HTTPException } from "hono/http-exception";
10import {
11 describeRoute,
12 openAPIRouteHandler,
13 resolver,
14 validator,
15} from "hono-openapi";
16import * as v from "valibot";
17import activity from "./activity";
18import { auth } from "./auth";
19import column from "./column";
20import comment from "./comment";
21import config from "./config";
22import db, { schema } from "./database";
23import discordIntegration from "./discord-integration";
24import externalLink from "./external-link";
25import genericWebhookIntegration from "./generic-webhook-integration";
26import giteaIntegration, { handleGiteaWebhookRoute } from "./gitea-integration";
27import githubIntegration, {
28 handleGithubWebhookRoute,
29} from "./github-integration";
30import invitation from "./invitation";
31import label from "./label";
32import { migrateColumns } from "./migrations/column-migration";
33import notification from "./notification";
34import notificationPreferences from "./notification-preferences";
35import { initializePlugins } from "./plugins";
36import { migrateGitHubIntegration } from "./plugins/github/migration";
37import project from "./project";
38import { getPublicProject } from "./project/controllers/get-public-project";
39import { initializeScheduler, shutdownScheduler } from "./scheduler";
40import search from "./search";
41import slackIntegration from "./slack-integration";
42import { getPrivateObject } from "./storage/s3";
43import task from "./task";
44import taskRelation from "./task-relation";
45import telegramIntegration from "./telegram-integration";
46import timeEntry from "./time-entry";
47import {
48 authenticateApiRequest,
49 resolveAssetBearerOrCookie,
50} from "./utils/authenticate-api-request";
51import { getInvitationDetails } from "./utils/check-registration-allowed";
52import { migrateApiKeyReferenceId } from "./utils/migrate-apikey-reference-id";
53import { migrateNotificationPreferencesSchema } from "./utils/migrate-notification-preferences-schema";
54import { migrateSessionColumn } from "./utils/migrate-session-column";
55import { migrateWorkspaceUserEmail } from "./utils/migrate-workspace-user-email";
56import {
57 dedupeOperationIds,
58 ensureOperationSummaries,
59 markOptionalSchemaFieldsNullable,
60 mergeOpenApiSpecs,
61 normalizeApiServerUrl,
62 normalizeEmptyRequiredArrays,
63 normalizeNullableSchemasForOpenApi30,
64 normalizeOrganizationAuthOperations,
65} from "./utils/openapi-spec";
66import { validateWorkspaceAccess } from "./utils/validate-workspace-access";
67import workflowRule from "./workflow-rule";
68import workspace from "./workspace";
69
70type ApiKey = {
71 id: string;
72 userId: string;
73 enabled: boolean;
74};
75
76type AppVariables = {
77 Variables: {
78 user: User | null;
79 session: Session | null;
80 userId: string;
81 apiKey?: ApiKey;
82 };
83};
84
85type ApiVariables = {
86 Variables: {
87 user: User | null;
88 session: Session | null;
89 userId: string;
90 userEmail: string;
91 apiKey?: ApiKey;
92 };
93};
94
95function buildContentDisposition(filename: string) {
96 const normalized = filename
97 .normalize("NFC")
98 .replace(/[\r\n"]/g, "")
99 .trim();
100 const safeFilename = normalized || "file";
101 const asciiFallback =
102 safeFilename
103 .normalize("NFKD")
104 .replace(/[\u0300-\u036f]/g, "")
105 .replace(/[\\/]/g, "-")
106 .replace(/[^\x20-\u7E]+/g, "_")
107 .replace(/\s+/g, " ")
108 .trim() || "file";
109 const encodedFilename = encodeURIComponent(safeFilename).replace(
110 /['()*]/g,
111 (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
112 );
113
114 return `inline; filename="${asciiFallback}"; filename*=UTF-8''${encodedFilename}`;
115}
116
117export function createApp() {
118 const app = new Hono<AppVariables>();
119 const corsOrigins = process.env.CORS_ORIGINS
120 ? process.env.CORS_ORIGINS.split(",").map((origin) => origin.trim())
121 : undefined;
122
123 app.use(
124 "*",
125 cors({
126 credentials: true,
127 origin: (origin) => {
128 if (!corsOrigins) {
129 return origin || "*";
130 }
131
132 if (!origin) {
133 return null;
134 }
135
136 return corsOrigins.includes(origin) ? origin : null;
137 },
138 }),
139 );
140
141 const api = new Hono<ApiVariables>();
142
143 api.get("/health", (c) => {
144 return c.json({ status: "ok" });
145 });
146
147 const publicProjectApi = api.get("/public-project/:id", async (c) => {
148 const { id } = c.req.param();
149 const project = await getPublicProject(id);
150
151 return c.json(project);
152 });
153
154 api.post("/github-integration/webhook", handleGithubWebhookRoute);
155
156 api.post(
157 "/gitea-integration/webhook/:integrationId",
158 handleGiteaWebhookRoute,
159 );
160
161 const invitationPublicApi = api.get("/invitation/public/:id", async (c) => {
162 const { id } = c.req.param();
163 const result = await getInvitationDetails(id);
164 return c.json(result);
165 });
166
167 api.get(
168 "/auth/get-session",
169 describeRoute({
170 operationId: "getSession",
171 tags: ["Authentication"],
172 description: "Get the current authenticated session",
173 security: [],
174 responses: {
175 200: {
176 description: "Current session details or null when unauthenticated",
177 content: {
178 "application/json": { schema: resolver(v.any()) },
179 },
180 },
181 },
182 }),
183 async (c) => {
184 const session = await auth.api.getSession({ headers: c.req.raw.headers });
185 return c.json(session ?? null);
186 },
187 );
188
189 api.get(
190 "/asset/:id",
191 describeRoute({
192 operationId: "getAsset",
193 tags: ["Assets"],
194 description: "Download an uploaded asset by ID",
195 security: [],
196 responses: {
197 200: {
198 description: "The requested asset binary stream",
199 content: {
200 "*/*": { schema: resolver(v.any()) },
201 },
202 },
203 },
204 }),
205 validator("param", v.object({ id: v.string() })),
206 async (c) => {
207 const { id } = c.req.param();
208 const [asset] = await db
209 .select({
210 id: schema.assetTable.id,
211 objectKey: schema.assetTable.objectKey,
212 mimeType: schema.assetTable.mimeType,
213 filename: schema.assetTable.filename,
214 workspaceId: schema.assetTable.workspaceId,
215 isPublic: schema.projectTable.isPublic,
216 })
217 .from(schema.assetTable)
218 .innerJoin(
219 schema.projectTable,
220 eq(schema.assetTable.projectId, schema.projectTable.id),
221 )
222 .where(eq(schema.assetTable.id, id))
223 .limit(1);
224
225 if (!asset) {
226 throw new HTTPException(404, { message: "Asset not found" });
227 }
228
229 const { userId, apiKeyId } = await resolveAssetBearerOrCookie(c);
230
231 if (userId) {
232 await validateWorkspaceAccess(userId, asset.workspaceId, apiKeyId);
233 } else if (!asset.isPublic) {
234 throw new HTTPException(401, { message: "Unauthorized" });
235 }
236
237 try {
238 const object = await getPrivateObject(asset.objectKey);
239
240 return new Response(object.body as BodyInit, {
241 headers: {
242 "Cache-Control": asset.isPublic
243 ? "public, max-age=300"
244 : "private, max-age=120",
245 "Content-Disposition": buildContentDisposition(asset.filename),
246 "Content-Length": object.contentLength?.toString() || "",
247 "Content-Type": object.contentType || asset.mimeType,
248 ETag: object.etag || "",
249 "Last-Modified": object.lastModified?.toUTCString() || "",
250 },
251 });
252 } catch (error) {
253 console.error("Failed to stream asset:", error);
254 throw new HTTPException(404, { message: "Asset object not found" });
255 }
256 },
257 );
258
259 const configApi = api.route("/config", config);
260
261 const honoOpenApiHandler = openAPIRouteHandler(api, {
262 documentation: {
263 openapi: "3.0.3",
264 info: {
265 title: "Kaneo API",
266 version: "1.0.0",
267 description:
268 "Kaneo Project Management API - Manage projects, tasks, labels, and more",
269 },
270 servers: [
271 {
272 url: normalizeApiServerUrl(
273 process.env.KANEO_API_URL || "https://cloud.kaneo.app",
274 ),
275 description: "Kaneo API Server",
276 },
277 ],
278 components: {
279 securitySchemes: {
280 bearerAuth: {
281 type: "http",
282 scheme: "bearer",
283 description: "API key or session token (Bearer)",
284 },
285 },
286 },
287 security: [{ bearerAuth: [] }],
288 },
289 });
290
291 api.get("/openapi", async (c) => {
292 const maybeResponse = await honoOpenApiHandler(c, async () => {});
293 const honoSpecResponse = maybeResponse ?? c.res;
294 const honoSpec = (await honoSpecResponse.json()) as Record<string, unknown>;
295
296 let authSpec: Record<string, unknown> = {};
297 try {
298 authSpec = (await auth.api.generateOpenAPISchema()) as Record<
299 string,
300 unknown
301 >;
302 } catch (error) {
303 console.error("Failed to generate Better Auth OpenAPI schema:", error);
304 }
305
306 const normalizedAuthSpec = normalizeOrganizationAuthOperations(authSpec);
307 return c.json(
308 ensureOperationSummaries(
309 dedupeOperationIds(
310 markOptionalSchemaFieldsNullable(
311 normalizeNullableSchemasForOpenApi30(
312 normalizeEmptyRequiredArrays(
313 mergeOpenApiSpecs(honoSpec, normalizedAuthSpec),
314 ),
315 ),
316 ),
317 ),
318 ),
319 );
320 });
321
322 // Better Auth serves GET /auth/device as JSON. Browsers that open the API URL
323 // directly expect a page — redirect full document navigations to the web app.
324 const authDeviceQuerySchema = v.object({
325 user_code: v.optional(v.string()),
326 ui: v.optional(v.picklist(["1"])),
327 });
328
329 api.get(
330 "/auth/device",
331 describeRoute({
332 operationId: "getDeviceAuthorizationPage",
333 tags: ["Authentication"],
334 description:
335 "Redirect browser-based device authorization requests to the web UI",
336 security: [],
337 parameters: [
338 {
339 name: "user_code",
340 in: "query",
341 required: false,
342 schema: {
343 type: "string",
344 },
345 description: "The device authorization user code.",
346 },
347 {
348 name: "ui",
349 in: "query",
350 required: false,
351 schema: {
352 type: "string",
353 enum: ["1"],
354 },
355 description: "Force a redirect to the web UI.",
356 },
357 ],
358 responses: {
359 302: {
360 description: "Redirects the browser to the web app device screen",
361 },
362 200: {
363 description: "Device authorization payload from Better Auth",
364 content: {
365 "application/json": { schema: resolver(v.any()) },
366 },
367 },
368 },
369 }),
370 validator("query", authDeviceQuerySchema),
371 async (c) => {
372 const { user_code: userCode, ui } = c.req.valid("query");
373 const secFetchDest = c.req.header("Sec-Fetch-Dest");
374 const forceUiRedirect = ui === "1";
375 // Top-level browser tab / address bar (not `fetch()` / XHR from the SPA).
376 // Optional `ui=1` forces redirect when Sec-Fetch-* headers are missing (e.g. some clients).
377 if (forceUiRedirect || secFetchDest === "document") {
378 const clientUrl = (
379 process.env.KANEO_CLIENT_URL || "http://localhost:5173"
380 ).replace(/\/$/, "");
381 const deviceUrl = new URL(`${clientUrl}/device`);
382 if (userCode) {
383 deviceUrl.searchParams.set("user_code", userCode);
384 }
385 return c.redirect(deviceUrl.toString(), 302);
386 }
387 return auth.handler(c.req.raw);
388 },
389 );
390
391 api.on(["POST", "GET", "PUT", "DELETE"], "/auth/*", async (c) => {
392 const authHeader = c.req.header("Authorization");
393 const apiKeyHeader = c.req.header("x-api-key");
394 const bearerMatch = authHeader?.match(/^Bearer\s+(\S+)$/i);
395
396 if (bearerMatch && !apiKeyHeader) {
397 const session = await auth.api.getSession({
398 headers: c.req.raw.headers,
399 });
400
401 // Preserve Better Auth bearer session tokens on auth routes.
402 if (session?.session && session.user) {
403 return auth.handler(c.req.raw);
404 }
405
406 const headers = new Headers(c.req.raw.headers);
407
408 // Better Auth API key plugin validates from x-api-key by default.
409 headers.set("x-api-key", bearerMatch[1]);
410
411 return auth.handler(
412 new Request(c.req.raw, {
413 headers,
414 }),
415 );
416 }
417
418 return auth.handler(c.req.raw);
419 });
420
421 api.use("*", async (c, next) => {
422 try {
423 await authenticateApiRequest(c);
424 } catch (error) {
425 if (error instanceof HTTPException) {
426 throw error;
427 }
428 console.error("API authentication failed:", error);
429 throw new HTTPException(500, { message: "Internal Server Error" });
430 }
431 return next();
432 });
433
434 const projectApi = api.route("/project", project);
435 const taskApi = api.route("/task", task);
436 const columnApi = api.route("/column", column);
437 const activityApi = api.route("/activity", activity);
438 const commentApi = api.route("/comment", comment);
439 const timeEntryApi = api.route("/time-entry", timeEntry);
440 const labelApi = api.route("/label", label);
441 const notificationApi = api.route("/notification", notification);
442 const notificationPreferencesApi = api.route(
443 "/notification-preferences",
444 notificationPreferences,
445 );
446 const searchApi = api.route("/search", search);
447 const githubIntegrationApi = api.route(
448 "/github-integration",
449 githubIntegration,
450 );
451 const giteaIntegrationApi = api.route("/gitea-integration", giteaIntegration);
452 const genericWebhookIntegrationApi = api.route(
453 "/generic-webhook-integration",
454 genericWebhookIntegration,
455 );
456 const discordIntegrationApi = api.route(
457 "/discord-integration",
458 discordIntegration,
459 );
460 const slackIntegrationApi = api.route("/slack-integration", slackIntegration);
461 const telegramIntegrationApi = api.route(
462 "/telegram-integration",
463 telegramIntegration,
464 );
465 const taskRelationApi = api.route("/task-relation", taskRelation);
466 const externalLinkApi = api.route("/external-link", externalLink);
467 const workflowRuleApi = api.route("/workflow-rule", workflowRule);
468 const invitationApi = api.route("/invitation", invitation);
469 const workspaceApi = api.route("/workspace", workspace);
470
471 app.route("/api", api);
472
473 return {
474 app,
475 api,
476 activityApi,
477 columnApi,
478 commentApi,
479 configApi,
480 discordIntegrationApi,
481 externalLinkApi,
482 genericWebhookIntegrationApi,
483 githubIntegrationApi,
484 giteaIntegrationApi,
485 invitationApi,
486 invitationPublicApi,
487 labelApi,
488 notificationApi,
489 notificationPreferencesApi,
490 projectApi,
491 publicProjectApi,
492 searchApi,
493 slackIntegrationApi,
494 taskApi,
495 taskRelationApi,
496 telegramIntegrationApi,
497 timeEntryApi,
498 workflowRuleApi,
499 workspaceApi,
500 };
501}
502
503export async function runStartupTasks() {
504 const currentDir = dirname(fileURLToPath(import.meta.url));
505
506 await migrateWorkspaceUserEmail();
507 await migrateSessionColumn();
508 await migrateApiKeyReferenceId();
509
510 console.log("🔄 Migrating database...");
511 await migrate(db, {
512 migrationsFolder: `${currentDir}/../drizzle`,
513 });
514 console.log("✅ Database migrated successfully!");
515
516 await migrateNotificationPreferencesSchema();
517 await migrateGitHubIntegration();
518 await migrateColumns();
519
520 initializePlugins();
521 initializeScheduler();
522}
523
524export async function startServer(port = 1337) {
525 try {
526 await runStartupTasks();
527 } catch (error) {
528 console.error("❌ Database migration failed!", error);
529 process.exit(1);
530 }
531
532 process.on("SIGTERM", () => {
533 shutdownScheduler();
534 });
535
536 serve(
537 {
538 fetch: app.fetch,
539 port,
540 },
541 () => {
542 console.log(
543 `⚡ API is running at ${process.env.KANEO_API_URL || "http://localhost:1337"}`,
544 );
545 },
546 );
547}
548
549const createdApp = createApp();
550const {
551 app,
552 activityApi,
553 columnApi,
554 commentApi,
555 configApi,
556 discordIntegrationApi,
557 externalLinkApi,
558 genericWebhookIntegrationApi,
559 githubIntegrationApi,
560 giteaIntegrationApi,
561 invitationApi,
562 invitationPublicApi,
563 labelApi,
564 notificationApi,
565 notificationPreferencesApi,
566 projectApi,
567 publicProjectApi,
568 searchApi,
569 slackIntegrationApi,
570 taskApi,
571 taskRelationApi,
572 telegramIntegrationApi,
573 timeEntryApi,
574 workflowRuleApi,
575 workspaceApi,
576} = createdApp;
577
578const isMainModule =
579 Boolean(process.argv[1]) &&
580 import.meta.url === pathToFileURL(process.argv[1]).href;
581
582if (isMainModule) {
583 void startServer();
584}
585
586export type AppType =
587 | typeof configApi
588 | typeof projectApi
589 | typeof taskApi
590 | typeof columnApi
591 | typeof activityApi
592 | typeof commentApi
593 | typeof timeEntryApi
594 | typeof labelApi
595 | typeof notificationApi
596 | typeof notificationPreferencesApi
597 | typeof searchApi
598 | typeof githubIntegrationApi
599 | typeof giteaIntegrationApi
600 | typeof genericWebhookIntegrationApi
601 | typeof discordIntegrationApi
602 | typeof slackIntegrationApi
603 | typeof telegramIntegrationApi
604 | typeof taskRelationApi
605 | typeof externalLinkApi
606 | typeof workflowRuleApi
607 | typeof invitationApi
608 | typeof workspaceApi
609 | typeof publicProjectApi
610 | typeof invitationPublicApi;
611
612export default app;