bopodev-api 0.1.12 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,22 @@
1
1
  import { Router } from "express";
2
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import { basename, extname, join, resolve } from "node:path";
2
4
  import { and, eq } from "drizzle-orm";
5
+ import multer from "multer";
3
6
  import { z } from "zod";
4
7
  import {
8
+ addIssueAttachment,
5
9
  addIssueComment,
6
10
  agents,
7
11
  appendActivity,
8
12
  appendAuditEvent,
9
13
  createIssue,
14
+ deleteIssueAttachment,
10
15
  deleteIssueComment,
11
16
  deleteIssue,
17
+ getIssueAttachment,
12
18
  issues,
19
+ listIssueAttachments,
13
20
  listIssueActivity,
14
21
  listIssueComments,
15
22
  listIssues,
@@ -17,8 +24,10 @@ import {
17
24
  updateIssueComment,
18
25
  updateIssue
19
26
  } from "bopodev-db";
27
+ import { nanoid } from "nanoid";
20
28
  import type { AppContext } from "../context";
21
29
  import { sendError, sendOk } from "../http";
30
+ import { isInsidePath, normalizeAbsolutePath, resolveProjectWorkspacePath } from "../lib/instance-paths";
22
31
  import { requireCompanyScope } from "../middleware/company-scope";
23
32
  import { requirePermission } from "../middleware/request-actor";
24
33
 
@@ -51,6 +60,31 @@ const updateIssueCommentSchema = z.object({
51
60
  body: z.string().min(1)
52
61
  });
53
62
 
63
+ const MAX_ATTACHMENTS_PER_REQUEST = parsePositiveIntEnv("BOPO_ISSUE_ATTACHMENTS_MAX_FILES", 10);
64
+ const MAX_ATTACHMENT_SIZE_BYTES = parsePositiveIntEnv("BOPO_ISSUE_ATTACHMENTS_MAX_BYTES", 20 * 1024 * 1024);
65
+ const ALLOWED_ATTACHMENT_MIME_TYPES = parseCsvSet(
66
+ process.env.BOPO_ISSUE_ATTACHMENTS_ALLOWED_MIME_TYPES,
67
+ [
68
+ "image/png",
69
+ "image/jpeg",
70
+ "image/webp",
71
+ "image/gif",
72
+ "application/pdf",
73
+ "text/plain",
74
+ "text/markdown",
75
+ "application/json",
76
+ "text/csv",
77
+ "application/zip",
78
+ "application/x-zip-compressed"
79
+ ]
80
+ );
81
+ const ALLOWED_ATTACHMENT_EXTENSIONS = parseCsvSet(
82
+ process.env.BOPO_ISSUE_ATTACHMENTS_ALLOWED_EXTENSIONS,
83
+ ["png", "jpg", "jpeg", "webp", "gif", "pdf", "txt", "md", "json", "csv", "zip"]
84
+ );
85
+
86
+ type IssueAttachmentResponse = Record<string, unknown> & { id: string; downloadPath: string };
87
+
54
88
  function parseStringArray(value: unknown) {
55
89
  if (Array.isArray(value)) {
56
90
  return value.map((entry) => String(entry));
@@ -92,6 +126,13 @@ const updateIssueSchema = z
92
126
 
93
127
  export function createIssuesRouter(ctx: AppContext) {
94
128
  const router = Router();
129
+ const upload = multer({
130
+ storage: multer.memoryStorage(),
131
+ limits: {
132
+ fileSize: MAX_ATTACHMENT_SIZE_BYTES,
133
+ files: MAX_ATTACHMENTS_PER_REQUEST
134
+ }
135
+ });
95
136
  router.use(requireCompanyScope);
96
137
 
97
138
  router.get("/", async (req, res) => {
@@ -142,6 +183,199 @@ export function createIssuesRouter(ctx: AppContext) {
142
183
  return sendOk(res, toIssueResponse(issue as unknown as Record<string, unknown>));
143
184
  });
144
185
 
186
+ router.post("/:issueId/attachments", async (req, res) => {
187
+ requirePermission("issues:write")(req, res, () => {});
188
+ if (res.headersSent) {
189
+ return;
190
+ }
191
+
192
+ upload.array("files", MAX_ATTACHMENTS_PER_REQUEST)(req, res, async (uploadError) => {
193
+ if (uploadError) {
194
+ if (uploadError instanceof multer.MulterError) {
195
+ if (uploadError.code === "LIMIT_FILE_SIZE") {
196
+ return sendError(
197
+ res,
198
+ `Attachment exceeds max file size of ${MAX_ATTACHMENT_SIZE_BYTES} bytes.`,
199
+ 422
200
+ );
201
+ }
202
+ if (uploadError.code === "LIMIT_FILE_COUNT") {
203
+ return sendError(
204
+ res,
205
+ `Too many files. Max ${MAX_ATTACHMENTS_PER_REQUEST} attachment(s) per request.`,
206
+ 422
207
+ );
208
+ }
209
+ return sendError(res, uploadError.message, 422);
210
+ }
211
+ return sendError(res, "Failed to parse multipart attachment payload.", 422);
212
+ }
213
+
214
+ try {
215
+ const files = (req.files as Express.Multer.File[] | undefined) ?? [];
216
+ if (files.length === 0) {
217
+ return sendError(res, "At least one attachment file is required.", 422);
218
+ }
219
+ const issueContext = await getIssueContextForAttachment(ctx, req.companyId!, req.params.issueId);
220
+ if (!issueContext) {
221
+ return sendError(res, "Issue not found.", 404);
222
+ }
223
+ const workspacePath = resolveWorkspacePath(issueContext.companyId, issueContext.projectId, issueContext.workspaceLocalPath);
224
+ const attachmentDir = join(workspacePath, ".bopo", "issues", issueContext.issueId, "attachments");
225
+ await mkdir(attachmentDir, { recursive: true });
226
+
227
+ const uploaded: IssueAttachmentResponse[] = [];
228
+ for (const file of files) {
229
+ if (!isAllowedAttachmentFile(file)) {
230
+ return sendError(
231
+ res,
232
+ `Unsupported attachment type for '${file.originalname}'. Allowed extensions: ${Array.from(ALLOWED_ATTACHMENT_EXTENSIONS).join(", ")}`,
233
+ 422
234
+ );
235
+ }
236
+
237
+ const attachmentId = nanoid(14);
238
+ const safeFileName = sanitizeAttachmentFileName(file.originalname);
239
+ const storedFileName = `${attachmentId}-${safeFileName}`;
240
+ const relativePath = join(".bopo", "issues", issueContext.issueId, "attachments", storedFileName);
241
+ const absolutePath = resolve(workspacePath, relativePath);
242
+ if (!isInsidePath(workspacePath, absolutePath)) {
243
+ return sendError(res, "Invalid attachment destination path.", 422);
244
+ }
245
+
246
+ await writeFile(absolutePath, file.buffer);
247
+ try {
248
+ const attachment = await addIssueAttachment(ctx.db, {
249
+ id: attachmentId,
250
+ companyId: req.companyId!,
251
+ issueId: issueContext.issueId,
252
+ projectId: issueContext.projectId,
253
+ fileName: file.originalname,
254
+ mimeType: file.mimetype || null,
255
+ fileSizeBytes: file.size,
256
+ relativePath,
257
+ uploadedByActorType: req.actor?.type === "agent" ? "agent" : "human",
258
+ uploadedByActorId: req.actor?.id
259
+ });
260
+ uploaded.push(toIssueAttachmentResponse(attachment as unknown as Record<string, unknown>, issueContext.issueId));
261
+ } catch (error) {
262
+ await rm(absolutePath, { force: true }).catch(() => undefined);
263
+ throw error;
264
+ }
265
+ }
266
+
267
+ await appendActivity(ctx.db, {
268
+ companyId: req.companyId!,
269
+ issueId: issueContext.issueId,
270
+ actorType: req.actor?.type === "agent" ? "agent" : "human",
271
+ actorId: req.actor?.id,
272
+ eventType: "issue.attachments_added",
273
+ payload: { count: uploaded.length, attachmentIds: uploaded.map((entry) => entry.id) }
274
+ });
275
+ await appendAuditEvent(ctx.db, {
276
+ companyId: req.companyId!,
277
+ actorType: req.actor?.type === "agent" ? "agent" : "human",
278
+ actorId: req.actor?.id,
279
+ eventType: "issue.attachments_added",
280
+ entityType: "issue",
281
+ entityId: issueContext.issueId,
282
+ payload: { attachments: uploaded }
283
+ });
284
+ return sendOk(res, uploaded);
285
+ } catch (error) {
286
+ // eslint-disable-next-line no-console
287
+ console.error(error);
288
+ return sendError(res, "Failed to upload attachments.", 500);
289
+ }
290
+ });
291
+ });
292
+
293
+ router.get("/:issueId/attachments", async (req, res) => {
294
+ const issueContext = await getIssueContextForAttachment(ctx, req.companyId!, req.params.issueId);
295
+ if (!issueContext) {
296
+ return sendError(res, "Issue not found.", 404);
297
+ }
298
+ const attachments = await listIssueAttachments(ctx.db, req.companyId!, req.params.issueId);
299
+ return sendOk(
300
+ res,
301
+ attachments.map((attachment) =>
302
+ toIssueAttachmentResponse(attachment as unknown as Record<string, unknown>, req.params.issueId)
303
+ )
304
+ );
305
+ });
306
+
307
+ router.get("/:issueId/attachments/:attachmentId/download", async (req, res) => {
308
+ const issueContext = await getIssueContextForAttachment(ctx, req.companyId!, req.params.issueId);
309
+ if (!issueContext) {
310
+ return sendError(res, "Issue not found.", 404);
311
+ }
312
+ const attachment = await getIssueAttachment(ctx.db, req.companyId!, req.params.issueId, req.params.attachmentId);
313
+ if (!attachment) {
314
+ return sendError(res, "Attachment not found.", 404);
315
+ }
316
+ const workspacePath = resolveWorkspacePath(issueContext.companyId, issueContext.projectId, issueContext.workspaceLocalPath);
317
+ const absolutePath = resolve(workspacePath, attachment.relativePath);
318
+ if (!isInsidePath(workspacePath, absolutePath)) {
319
+ return sendError(res, "Invalid attachment path.", 422);
320
+ }
321
+ try {
322
+ await stat(absolutePath);
323
+ } catch {
324
+ return sendError(res, "Attachment file is missing on disk.", 404);
325
+ }
326
+ const fileBuffer = await readFile(absolutePath);
327
+ if (attachment.mimeType) {
328
+ res.setHeader("content-type", attachment.mimeType);
329
+ } else {
330
+ res.setHeader("content-type", "application/octet-stream");
331
+ }
332
+ res.setHeader("content-disposition", `attachment; filename="${encodeURIComponent(attachment.fileName)}"`);
333
+ return res.send(fileBuffer);
334
+ });
335
+
336
+ router.delete("/:issueId/attachments/:attachmentId", async (req, res) => {
337
+ requirePermission("issues:write")(req, res, () => {});
338
+ if (res.headersSent) {
339
+ return;
340
+ }
341
+ const issueContext = await getIssueContextForAttachment(ctx, req.companyId!, req.params.issueId);
342
+ if (!issueContext) {
343
+ return sendError(res, "Issue not found.", 404);
344
+ }
345
+ const attachment = await getIssueAttachment(ctx.db, req.companyId!, req.params.issueId, req.params.attachmentId);
346
+ if (!attachment) {
347
+ return sendError(res, "Attachment not found.", 404);
348
+ }
349
+ const workspacePath = resolveWorkspacePath(issueContext.companyId, issueContext.projectId, issueContext.workspaceLocalPath);
350
+ const absolutePath = resolve(workspacePath, attachment.relativePath);
351
+ if (!isInsidePath(workspacePath, absolutePath)) {
352
+ return sendError(res, "Invalid attachment path.", 422);
353
+ }
354
+ await rm(absolutePath, { force: true }).catch(() => undefined);
355
+ const deleted = await deleteIssueAttachment(ctx.db, req.companyId!, req.params.issueId, req.params.attachmentId);
356
+ if (!deleted) {
357
+ return sendError(res, "Attachment not found.", 404);
358
+ }
359
+ await appendActivity(ctx.db, {
360
+ companyId: req.companyId!,
361
+ issueId: issueContext.issueId,
362
+ actorType: req.actor?.type === "agent" ? "agent" : "human",
363
+ actorId: req.actor?.id,
364
+ eventType: "issue.attachment_deleted",
365
+ payload: { attachmentId: req.params.attachmentId }
366
+ });
367
+ await appendAuditEvent(ctx.db, {
368
+ companyId: req.companyId!,
369
+ actorType: req.actor?.type === "agent" ? "agent" : "human",
370
+ actorId: req.actor?.id,
371
+ eventType: "issue.attachment_deleted",
372
+ entityType: "issue_attachment",
373
+ entityId: req.params.attachmentId,
374
+ payload: deleted as unknown as Record<string, unknown>
375
+ });
376
+ return sendOk(res, { deleted: true });
377
+ });
378
+
145
379
  router.get("/:issueId/comments", async (req, res) => {
146
380
  const comments = await listIssueComments(ctx.db, req.companyId!, req.params.issueId);
147
381
  return sendOk(res, comments);
@@ -435,3 +669,90 @@ async function validateIssueAssignmentScope(
435
669
  return null;
436
670
  }
437
671
 
672
+ function parsePositiveIntEnv(name: string, fallback: number) {
673
+ const raw = process.env[name];
674
+ if (!raw) {
675
+ return fallback;
676
+ }
677
+ const parsed = Number.parseInt(raw, 10);
678
+ if (!Number.isFinite(parsed) || parsed <= 0) {
679
+ return fallback;
680
+ }
681
+ return parsed;
682
+ }
683
+
684
+ function parseCsvSet(raw: string | undefined, fallback: string[]) {
685
+ if (!raw || raw.trim().length === 0) {
686
+ return new Set(fallback);
687
+ }
688
+ return new Set(
689
+ raw
690
+ .split(",")
691
+ .map((value) => value.trim().toLowerCase())
692
+ .filter(Boolean)
693
+ );
694
+ }
695
+
696
+ function sanitizeAttachmentFileName(input: string) {
697
+ const original = basename(input || "attachment");
698
+ const sanitized = original.replace(/[^a-zA-Z0-9._-]/g, "_");
699
+ return sanitized.length > 0 ? sanitized : "attachment";
700
+ }
701
+
702
+ function isAllowedAttachmentFile(file: Express.Multer.File) {
703
+ const extension = extname(file.originalname).slice(1).toLowerCase();
704
+ const mime = (file.mimetype ?? "").toLowerCase();
705
+ if (extension && ALLOWED_ATTACHMENT_EXTENSIONS.has(extension)) {
706
+ return true;
707
+ }
708
+ return mime.length > 0 && ALLOWED_ATTACHMENT_MIME_TYPES.has(mime);
709
+ }
710
+
711
+ function toIssueAttachmentResponse(attachment: Record<string, unknown>, issueId: string) {
712
+ const id = String(attachment.id ?? "");
713
+ return {
714
+ ...attachment,
715
+ id,
716
+ downloadPath: `/issues/${issueId}/attachments/${id}/download`
717
+ };
718
+ }
719
+
720
+ function resolveWorkspacePath(companyId: string, projectId: string, workspaceLocalPath: string | null) {
721
+ if (workspaceLocalPath && workspaceLocalPath.trim().length > 0) {
722
+ return normalizeAbsolutePath(workspaceLocalPath);
723
+ }
724
+ return resolveProjectWorkspacePath(companyId, projectId);
725
+ }
726
+
727
+ async function getIssueContextForAttachment(ctx: AppContext, companyId: string, issueId: string) {
728
+ const [issue] = await ctx.db
729
+ .select({
730
+ issueId: issues.id,
731
+ companyId: issues.companyId,
732
+ projectId: issues.projectId
733
+ })
734
+ .from(issues)
735
+ .where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)))
736
+ .limit(1);
737
+ if (!issue) {
738
+ return null;
739
+ }
740
+ const [project] = await ctx.db
741
+ .select({
742
+ id: projects.id,
743
+ workspaceLocalPath: projects.workspaceLocalPath
744
+ })
745
+ .from(projects)
746
+ .where(and(eq(projects.companyId, companyId), eq(projects.id, issue.projectId)))
747
+ .limit(1);
748
+ if (!project) {
749
+ return null;
750
+ }
751
+ return {
752
+ issueId: issue.issueId,
753
+ companyId: issue.companyId,
754
+ projectId: issue.projectId,
755
+ workspaceLocalPath: project.workspaceLocalPath
756
+ };
757
+ }
758
+