kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { serve } from "@hono/node-server";
2import type { Session, User } from "better-auth/types";
3import { eq } from "drizzle-orm";
4import { migrate } from "drizzle-orm/node-postgres/migrator";
5import { Hono } from "hono";
6import { cors } from "hono/cors";
7import { HTTPException } from "hono/http-exception";
8import {
9 describeRoute,
10 openAPIRouteHandler,
11 resolver,
12 validator,
13} from "hono-openapi";
14import * as v from "valibot";
15import activity from "./activity";
16import { auth } from "./auth";
17import column from "./column";
18import config from "./config";
19import db, { schema } from "./database";
20import externalLink from "./external-link";
21import githubIntegration, {
22 handleGithubWebhookRoute,
23} from "./github-integration";
24import invitation from "./invitation";
25import label from "./label";
26import { migrateColumns } from "./migrations/column-migration";
27import notification from "./notification";
28import { initializePlugins } from "./plugins";
29import { migrateGitHubIntegration } from "./plugins/github/migration";
30import project from "./project";
31import { getPublicProject } from "./project/controllers/get-public-project";
32import search from "./search";
33import { getPrivateObject } from "./storage/s3";
34import task from "./task";
35import timeEntry from "./time-entry";
36import { getInvitationDetails } from "./utils/check-registration-allowed";
37import { migrateApiKeyReferenceId } from "./utils/migrate-apikey-reference-id";
38import { migrateSessionColumn } from "./utils/migrate-session-column";
39import { migrateWorkspaceUserEmail } from "./utils/migrate-workspace-user-email";
40import {
41 dedupeOperationIds,
42 ensureOperationSummaries,
43 mergeOpenApiSpecs,
44 normalizeApiServerUrl,
45 normalizeEmptyRequiredArrays,
46 normalizeNullableSchemasForOpenApi30,
47 normalizeOrganizationAuthOperations,
48} from "./utils/openapi-spec";
49import { validateWorkspaceAccess } from "./utils/validate-workspace-access";
50import { verifyApiKey } from "./utils/verify-api-key";
51import workflowRule from "./workflow-rule";
52
53type ApiKey = {
54 id: string;
55 userId: string;
56 enabled: boolean;
57};
58
59function buildContentDisposition(filename: string) {
60 const normalized = filename
61 .normalize("NFC")
62 .replace(/[\r\n"]/g, "")
63 .trim();
64 const safeFilename = normalized || "file";
65 const asciiFallback =
66 safeFilename
67 .normalize("NFKD")
68 .replace(/[\u0300-\u036f]/g, "")
69 .replace(/[\\/]/g, "-")
70 .replace(/[^\x20-\x7E]+/g, "_")
71 .replace(/\s+/g, " ")
72 .trim() || "file";
73 const encodedFilename = encodeURIComponent(safeFilename).replace(
74 /['()*]/g,
75 (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
76 );
77
78 return `inline; filename="${asciiFallback}"; filename*=UTF-8''${encodedFilename}`;
79}
80
81const app = new Hono<{
82 Variables: {
83 user: User | null;
84 session: Session | null;
85 userId: string;
86 apiKey?: ApiKey;
87 };
88}>();
89
90const corsOrigins = process.env.CORS_ORIGINS
91 ? process.env.CORS_ORIGINS.split(",").map((origin) => origin.trim())
92 : undefined;
93
94app.use(
95 "*",
96 cors({
97 credentials: true,
98 origin: (origin) => {
99 if (!corsOrigins) {
100 return origin || "*";
101 }
102
103 if (!origin) {
104 return null;
105 }
106
107 return corsOrigins.includes(origin) ? origin : null;
108 },
109 }),
110);
111
112const api = new Hono<{
113 Variables: {
114 user: User | null;
115 session: Session | null;
116 userId: string;
117 userEmail: string;
118 apiKey?: ApiKey;
119 };
120}>();
121
122api.get("/health", (c) => {
123 return c.json({ status: "ok" });
124});
125
126const publicProjectApi = api.get("/public-project/:id", async (c) => {
127 const { id } = c.req.param();
128 const project = await getPublicProject(id);
129
130 return c.json(project);
131});
132
133api.post("/github-integration/webhook", handleGithubWebhookRoute);
134
135const invitationPublicApi = api.get("/invitation/public/:id", async (c) => {
136 const { id } = c.req.param();
137 const result = await getInvitationDetails(id);
138 return c.json(result);
139});
140
141api.get(
142 "/auth/get-session",
143 describeRoute({
144 operationId: "getSession",
145 tags: ["Authentication"],
146 description: "Get the current authenticated session",
147 security: [],
148 responses: {
149 200: {
150 description: "Current session details or null when unauthenticated",
151 content: {
152 "application/json": { schema: resolver(v.any()) },
153 },
154 },
155 },
156 }),
157 async (c) => {
158 const session = await auth.api.getSession({ headers: c.req.raw.headers });
159 return c.json(session ?? null);
160 },
161);
162
163api.get(
164 "/asset/:id",
165 describeRoute({
166 operationId: "getAsset",
167 tags: ["Assets"],
168 description: "Download an uploaded asset by ID",
169 security: [],
170 responses: {
171 200: {
172 description: "The requested asset binary stream",
173 content: {
174 "*/*": { schema: resolver(v.any()) },
175 },
176 },
177 },
178 }),
179 validator("param", v.object({ id: v.string() })),
180 async (c) => {
181 const { id } = c.req.param();
182 const [asset] = await db
183 .select({
184 id: schema.assetTable.id,
185 objectKey: schema.assetTable.objectKey,
186 mimeType: schema.assetTable.mimeType,
187 filename: schema.assetTable.filename,
188 workspaceId: schema.assetTable.workspaceId,
189 isPublic: schema.projectTable.isPublic,
190 })
191 .from(schema.assetTable)
192 .innerJoin(
193 schema.projectTable,
194 eq(schema.assetTable.projectId, schema.projectTable.id),
195 )
196 .where(eq(schema.assetTable.id, id))
197 .limit(1);
198
199 if (!asset) {
200 throw new HTTPException(404, { message: "Asset not found" });
201 }
202
203 const authHeader = c.req.header("Authorization");
204 const bearerToken = authHeader?.startsWith("Bearer ")
205 ? authHeader.substring(7).replace(/\s+/g, "").trim()
206 : null;
207
208 let userId = "";
209 let apiKeyId: string | undefined;
210
211 if (bearerToken) {
212 const result = await verifyApiKey(bearerToken);
213
214 if (result?.valid && result.key) {
215 userId = result.key.userId;
216 apiKeyId = result.key.id;
217 } else {
218 throw new HTTPException(401, { message: "Invalid API key" });
219 }
220 } else {
221 const session = await auth.api.getSession({ headers: c.req.raw.headers });
222 userId = session?.user?.id || "";
223 }
224
225 if (userId) {
226 await validateWorkspaceAccess(userId, asset.workspaceId, apiKeyId);
227 } else if (!asset.isPublic) {
228 throw new HTTPException(401, { message: "Unauthorized" });
229 }
230
231 try {
232 const object = await getPrivateObject(asset.objectKey);
233
234 return new Response(object.body as BodyInit, {
235 headers: {
236 "Cache-Control": asset.isPublic
237 ? "public, max-age=300"
238 : "private, max-age=120",
239 "Content-Disposition": buildContentDisposition(asset.filename),
240 "Content-Length": object.contentLength?.toString() || "",
241 "Content-Type": object.contentType || asset.mimeType,
242 ETag: object.etag || "",
243 "Last-Modified": object.lastModified?.toUTCString() || "",
244 },
245 });
246 } catch (error) {
247 console.error("Failed to stream asset:", error);
248 throw new HTTPException(404, { message: "Asset object not found" });
249 }
250 },
251);
252
253const configApi = api.route("/config", config);
254
255const honoOpenApiHandler = openAPIRouteHandler(api, {
256 documentation: {
257 openapi: "3.0.3",
258 info: {
259 title: "Kaneo API",
260 version: "1.0.0",
261 description:
262 "Kaneo Project Management API - Manage projects, tasks, labels, and more",
263 },
264 servers: [
265 {
266 url: normalizeApiServerUrl(
267 process.env.KANEO_API_URL || "https://cloud.kaneo.app",
268 ),
269 description: "Kaneo API Server",
270 },
271 ],
272 components: {
273 securitySchemes: {
274 bearerAuth: {
275 type: "http",
276 scheme: "bearer",
277 description: "API Key authentication",
278 },
279 },
280 },
281 security: [{ bearerAuth: [] }],
282 },
283});
284
285api.get("/openapi", async (c) => {
286 const maybeResponse = await honoOpenApiHandler(c, async () => {});
287 const honoSpecResponse = maybeResponse ?? c.res;
288 const honoSpec = (await honoSpecResponse.json()) as Record<string, unknown>;
289
290 let authSpec: Record<string, unknown> = {};
291 try {
292 authSpec = (await auth.api.generateOpenAPISchema()) as Record<
293 string,
294 unknown
295 >;
296 } catch (error) {
297 console.error("Failed to generate Better Auth OpenAPI schema:", error);
298 }
299
300 const normalizedAuthSpec = normalizeOrganizationAuthOperations(authSpec);
301 return c.json(
302 ensureOperationSummaries(
303 dedupeOperationIds(
304 normalizeNullableSchemasForOpenApi30(
305 normalizeEmptyRequiredArrays(
306 mergeOpenApiSpecs(honoSpec, normalizedAuthSpec),
307 ),
308 ),
309 ),
310 ),
311 );
312});
313
314api.on(["POST", "GET", "PUT", "DELETE"], "/auth/*", (c) => {
315 const authHeader = c.req.header("Authorization");
316
317 if (authHeader?.startsWith("Bearer ")) {
318 const apiKey = authHeader.substring(7).replace(/\s+/g, "").trim();
319 const headers = new Headers(c.req.raw.headers);
320
321 // Better Auth API key plugin validates from x-api-key by default.
322 if (!headers.get("x-api-key")) {
323 headers.set("x-api-key", apiKey);
324 }
325
326 return auth.handler(
327 new Request(c.req.raw, {
328 headers,
329 }),
330 );
331 }
332
333 return auth.handler(c.req.raw);
334});
335
336api.use("*", async (c, next) => {
337 const authHeader = c.req.header("Authorization");
338 if (authHeader?.startsWith("Bearer ")) {
339 const apiKey = authHeader.substring(7).replace(/\s+/g, "").trim();
340
341 try {
342 const result = await verifyApiKey(apiKey);
343
344 if (result?.valid && result.key) {
345 c.set("userId", result.key.userId);
346 c.set("user", null);
347 c.set("session", null);
348 c.set("apiKey", {
349 id: result.key.id,
350 userId: result.key.userId,
351 enabled: result.key.enabled,
352 });
353 return next();
354 }
355
356 throw new HTTPException(401, { message: "Invalid API key" });
357 } catch (error) {
358 if (error instanceof HTTPException) {
359 throw error;
360 }
361 console.error("API key verification failed:", error);
362 throw new HTTPException(401, { message: "API key verification failed" });
363 }
364 }
365
366 const session = await auth.api.getSession({ headers: c.req.raw.headers });
367 c.set("user", session?.user || null);
368 c.set("session", session?.session || null);
369 c.set("userId", session?.user?.id || "");
370 c.set("userEmail", session?.user?.email || "");
371
372 if (!session?.user) {
373 throw new HTTPException(401, { message: "Unauthorized" });
374 }
375
376 return next();
377});
378
379const projectApi = api.route("/project", project);
380const taskApi = api.route("/task", task);
381const columnApi = api.route("/column", column);
382const activityApi = api.route("/activity", activity);
383const timeEntryApi = api.route("/time-entry", timeEntry);
384const labelApi = api.route("/label", label);
385const notificationApi = api.route("/notification", notification);
386const searchApi = api.route("/search", search);
387const githubIntegrationApi = api.route(
388 "/github-integration",
389 githubIntegration,
390);
391const externalLinkApi = api.route("/external-link", externalLink);
392const workflowRuleApi = api.route("/workflow-rule", workflowRule);
393const invitationApi = api.route("/invitation", invitation);
394
395app.route("/api", api);
396
397(async () => {
398 try {
399 await migrateWorkspaceUserEmail();
400 await migrateSessionColumn();
401 await migrateApiKeyReferenceId();
402
403 console.log("🔄 Migrating database...");
404 await migrate(db, {
405 migrationsFolder: `${process.cwd()}/drizzle`,
406 });
407 console.log("✅ Database migrated successfully!");
408
409 await migrateGitHubIntegration();
410 await migrateColumns();
411
412 initializePlugins();
413 } catch (error) {
414 console.error("❌ Database migration failed!", error);
415 process.exit(1);
416 }
417})();
418
419serve(
420 {
421 fetch: app.fetch,
422 port: 1337,
423 },
424 () => {
425 console.log(
426 `⚡ API is running at ${process.env.KANEO_API_URL || "http://localhost:1337"}`,
427 );
428 },
429);
430
431export type AppType =
432 | typeof configApi
433 | typeof projectApi
434 | typeof taskApi
435 | typeof columnApi
436 | typeof activityApi
437 | typeof timeEntryApi
438 | typeof labelApi
439 | typeof notificationApi
440 | typeof searchApi
441 | typeof githubIntegrationApi
442 | typeof externalLinkApi
443 | typeof workflowRuleApi
444 | typeof invitationApi
445 | typeof publicProjectApi
446 | typeof invitationPublicApi;
447
448export default app;