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