kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 39e2dfae265f26c8d6d888a560f50ab2d5d58b3f 612 lines 18 kB view raw
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;