kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
at main 448 lines 12 kB view raw
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;