kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { eq } from "drizzle-orm";
2import type { Context, Next } from "hono";
3import { HTTPException } from "hono/http-exception";
4import db, { schema } from "../database";
5import { validateWorkspaceAccess } from "./validate-workspace-access";
6
7type WorkspaceIdSource =
8 | { type: "query"; key: string }
9 | { type: "body"; key: string }
10 | { type: "param"; key: string }
11 | {
12 type: "lookup";
13 resource:
14 | "project"
15 | "task"
16 | "label"
17 | "timeEntry"
18 | "activity"
19 | "comment"
20 | "column"
21 | "workflowRule";
22 idKey: string;
23 };
24
25type WorkspaceAccessMiddlewareConfig = {
26 sources: WorkspaceIdSource[];
27};
28
29async function readJsonObjectBody(
30 c: Context,
31): Promise<Record<string, unknown>> {
32 const raw = (await c.req.json().catch(() => ({}))) || {};
33 if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
34 return {};
35 }
36 return raw as Record<string, unknown>;
37}
38
39export function workspaceAccessMiddleware(
40 config: WorkspaceAccessMiddlewareConfig,
41) {
42 return async (c: Context, next: Next) => {
43 const userId = c.get("userId");
44
45 if (!userId) {
46 throw new HTTPException(401, { message: "Unauthorized" });
47 }
48
49 let workspaceId: string | null = null;
50
51 for (const source of config.sources) {
52 if (source.type === "query") {
53 workspaceId = c.req.query(source.key) || null;
54 } else if (source.type === "body") {
55 const body = await readJsonObjectBody(c);
56 workspaceId =
57 typeof body[source.key] === "string" ? body[source.key] : null;
58 } else if (source.type === "param") {
59 workspaceId = c.req.param(source.key) || null;
60 } else if (source.type === "lookup") {
61 const body = await readJsonObjectBody(c);
62 const idFromBody =
63 typeof body[source.idKey] === "string" ? body[source.idKey] : null;
64 const id =
65 c.req.param(source.idKey) || c.req.query(source.idKey) || idFromBody;
66 if (id) {
67 workspaceId = await lookupWorkspaceId(source.resource, id);
68 }
69 }
70
71 if (workspaceId) {
72 break;
73 }
74 }
75
76 if (!workspaceId) {
77 throw new HTTPException(400, {
78 message: "Workspace ID could not be determined",
79 });
80 }
81
82 const apiKey = c.get("apiKey");
83 const apiKeyId = apiKey?.id;
84
85 await validateWorkspaceAccess(userId, workspaceId, apiKeyId);
86
87 c.set("workspaceId", workspaceId);
88
89 return next();
90 };
91}
92
93async function lookupWorkspaceId(
94 resource:
95 | "project"
96 | "task"
97 | "label"
98 | "timeEntry"
99 | "activity"
100 | "comment"
101 | "column"
102 | "workflowRule",
103 id: string,
104): Promise<string | null> {
105 try {
106 switch (resource) {
107 case "project": {
108 const [project] = await db
109 .select({ workspaceId: schema.projectTable.workspaceId })
110 .from(schema.projectTable)
111 .where(eq(schema.projectTable.id, id))
112 .limit(1);
113 return project?.workspaceId || null;
114 }
115
116 case "task": {
117 const [task] = await db
118 .select({
119 workspaceId: schema.projectTable.workspaceId,
120 })
121 .from(schema.taskTable)
122 .innerJoin(
123 schema.projectTable,
124 eq(schema.taskTable.projectId, schema.projectTable.id),
125 )
126 .where(eq(schema.taskTable.id, id))
127 .limit(1);
128 return task?.workspaceId || null;
129 }
130
131 case "label": {
132 const [label] = await db
133 .select({ workspaceId: schema.labelTable.workspaceId })
134 .from(schema.labelTable)
135 .where(eq(schema.labelTable.id, id))
136 .limit(1);
137 return label?.workspaceId || null;
138 }
139
140 case "timeEntry": {
141 const [timeEntry] = await db
142 .select({
143 workspaceId: schema.projectTable.workspaceId,
144 })
145 .from(schema.timeEntryTable)
146 .innerJoin(
147 schema.taskTable,
148 eq(schema.timeEntryTable.taskId, schema.taskTable.id),
149 )
150 .innerJoin(
151 schema.projectTable,
152 eq(schema.taskTable.projectId, schema.projectTable.id),
153 )
154 .where(eq(schema.timeEntryTable.id, id))
155 .limit(1);
156 return timeEntry?.workspaceId || null;
157 }
158
159 case "activity": {
160 const [activity] = await db
161 .select({
162 workspaceId: schema.projectTable.workspaceId,
163 })
164 .from(schema.activityTable)
165 .innerJoin(
166 schema.taskTable,
167 eq(schema.activityTable.taskId, schema.taskTable.id),
168 )
169 .innerJoin(
170 schema.projectTable,
171 eq(schema.taskTable.projectId, schema.projectTable.id),
172 )
173 .where(eq(schema.activityTable.id, id))
174 .limit(1);
175 return activity?.workspaceId || null;
176 }
177
178 case "comment": {
179 const [comment] = await db
180 .select({
181 workspaceId: schema.projectTable.workspaceId,
182 })
183 .from(schema.commentTable)
184 .innerJoin(
185 schema.taskTable,
186 eq(schema.commentTable.taskId, schema.taskTable.id),
187 )
188 .innerJoin(
189 schema.projectTable,
190 eq(schema.taskTable.projectId, schema.projectTable.id),
191 )
192 .where(eq(schema.commentTable.id, id))
193 .limit(1);
194 return comment?.workspaceId || null;
195 }
196
197 case "column": {
198 const [column] = await db
199 .select({
200 workspaceId: schema.projectTable.workspaceId,
201 })
202 .from(schema.columnTable)
203 .innerJoin(
204 schema.projectTable,
205 eq(schema.columnTable.projectId, schema.projectTable.id),
206 )
207 .where(eq(schema.columnTable.id, id))
208 .limit(1);
209 return column?.workspaceId || null;
210 }
211
212 case "workflowRule": {
213 const [workflowRule] = await db
214 .select({
215 workspaceId: schema.projectTable.workspaceId,
216 })
217 .from(schema.workflowRuleTable)
218 .innerJoin(
219 schema.projectTable,
220 eq(schema.workflowRuleTable.projectId, schema.projectTable.id),
221 )
222 .where(eq(schema.workflowRuleTable.id, id))
223 .limit(1);
224 return workflowRule?.workspaceId || null;
225 }
226
227 default:
228 return null;
229 }
230 } catch (error) {
231 console.error(`Error looking up workspaceId for ${resource}:`, error);
232 return null;
233 }
234}
235
236export const workspaceAccess = {
237 fromQuery: (key = "workspaceId") =>
238 workspaceAccessMiddleware({ sources: [{ type: "query", key }] }),
239
240 fromBody: (key = "workspaceId") =>
241 workspaceAccessMiddleware({ sources: [{ type: "body", key }] }),
242
243 fromParam: (key = "workspaceId") =>
244 workspaceAccessMiddleware({ sources: [{ type: "param", key }] }),
245
246 fromProject: (idKey = "id") =>
247 workspaceAccessMiddleware({
248 sources: [
249 { type: "query", key: "workspaceId" },
250 { type: "lookup", resource: "project", idKey },
251 ],
252 }),
253
254 fromTask: (idKey = "id") =>
255 workspaceAccessMiddleware({
256 sources: [
257 { type: "lookup", resource: "task", idKey },
258 { type: "query", key: "workspaceId" },
259 ],
260 }),
261
262 fromTaskId: (idKey = "taskId") =>
263 workspaceAccessMiddleware({
264 sources: [
265 { type: "lookup", resource: "task", idKey },
266 { type: "query", key: "workspaceId" },
267 ],
268 }),
269
270 fromLabel: (idKey = "id") =>
271 workspaceAccessMiddleware({
272 sources: [
273 { type: "lookup", resource: "label", idKey },
274 { type: "query", key: "workspaceId" },
275 ],
276 }),
277
278 fromTimeEntry: (idKey = "id") =>
279 workspaceAccessMiddleware({
280 sources: [
281 { type: "lookup", resource: "timeEntry", idKey },
282 { type: "query", key: "workspaceId" },
283 ],
284 }),
285
286 fromActivity: (idKey = "id") =>
287 workspaceAccessMiddleware({
288 sources: [
289 { type: "lookup", resource: "activity", idKey },
290 { type: "query", key: "workspaceId" },
291 ],
292 }),
293
294 fromComment: (idKey = "id") =>
295 workspaceAccessMiddleware({
296 sources: [
297 { type: "lookup", resource: "comment", idKey },
298 { type: "query", key: "workspaceId" },
299 ],
300 }),
301
302 fromColumn: (idKey = "id") =>
303 workspaceAccessMiddleware({
304 sources: [
305 { type: "lookup", resource: "column", idKey },
306 { type: "query", key: "workspaceId" },
307 ],
308 }),
309
310 fromWorkflowRule: (idKey = "id") =>
311 workspaceAccessMiddleware({
312 sources: [
313 { type: "lookup", resource: "workflowRule", idKey },
314 { type: "query", key: "workspaceId" },
315 ],
316 }),
317};