1import { Inject, Injectable } from "@nestjs/common";
2import type { Request } from "express";
3import {
4 CONTENT_TYPE_OVERRIDES,
5 isAllowedMime,
6 presignGet,
7} from "@factory/storage";
8import { PrismaService } from "../../common/prisma.service.js";
9import { AuditService } from "../../common/audit.service.js";
10import { EventBus } from "../../common/event-bus.js";
11import { ApiError } from "../../common/errors.js";
12import { actorIsAdminKey, actorLevel, assertCan, assertRole, type Level } from "../../lib/authorize.js";
13import { loadEnv } from "../../config/env.js";
14import { deletePrefix, filesBucketConfig, makeFilesClient, makeThumbsClient, signExpirySeconds } from "./s3.js";
15import type {
16 ListFilesInput,
17 RenameFileInput,
18 RollbackFileInput,
19 SetPermissionsInput,
20} from "./dto.js";
21import { adjustWorkspaceStorageBytes } from "./quota.js";
22import { toFileRecord } from "./record.js";
23
24
52@Injectable()
53export class FilesService {
54 constructor(
55 @Inject(PrismaService) private readonly prisma: PrismaService,
56 @Inject(AuditService) private readonly audit: AuditService,
57 @Inject(EventBus) private readonly events: EventBus,
58 ) {}
59
60
61 async get(fileId: string, userId: string, req: Request) {
62
63
64
65
66
67 const file = await this.requireReadable(fileId, userId, req, false, true);
68 if (!file) throw ApiError.notFound("File not found.");
69 const versions = await this.prisma.fileVersion.findMany({
70 where: { fileId },
71 orderBy: { version: "asc" },
72 select: { id: true, version: true, size: true, createdAt: true },
73 });
74 const versionList = versions.map((v) => ({
75 id: v.id,
76 version: v.version,
77 size: v.size,
78 createdAt: v.createdAt.toISOString(),
79 }));
80
81
82
83 if (file.status === "VIRUS_DETECTED") {
84 return { ...toFileRecord(file, versions.length || 1), versions: versionList };
85 }
86 const urls = this.signedUrls(file);
87 return {
88 ...toFileRecord(file, versions.length || 1),
89 downloadUrl: urls.downloadUrl,
90 previewUrl: urls.previewUrl,
91 expiresAt: urls.expiresAt,
92 versions: versionList,
93 };
94 }
95
96
101 async download(fileId: string, userId: string, req: Request): Promise<{ location: string; expiresAt: string }> {
102 const file = await this.requireReadable(fileId, userId, req, true);
103 if (!file) throw ApiError.notFound("File not found.");
104 const urls = this.signedUrls(file, "attachment");
105 await this.audit.audit(req, req.auth, {
106 action: "file.downloaded",
107 resourceType: "file",
108 resourceId: file.id,
109 workspaceId: file.workspaceId,
110 metadata: { name: file.name, size: file.size, mimeType: file.mimeType },
111 });
112 return { location: urls.downloadUrl, expiresAt: urls.expiresAt };
113 }
114
115
117 async preview(fileId: string, userId: string, req: Request): Promise<{ location: string; expiresAt: string }> {
118 const file = await this.requireReadable(fileId, userId, req, true);
119 if (!file) throw ApiError.notFound("File not found.");
120 const urls = this.signedUrls(file, "inline");
121 return { location: urls.previewUrl, expiresAt: urls.expiresAt };
122 }
123
124
139 async list(workspaceId: string, userId: string, query: ListFilesInput) {
140 await assertCan(this.prisma, { workspaceId, userId }, "VIEW");
141 const sort = query.sort;
142 const col: "name" | "size" | "createdAt" | "updatedAt" =
143 sort === "name" ? "name" : sort === "size" ? "size" : sort === "date" ? "createdAt" : "updatedAt";
144 const dir: "asc" | "desc" = sort === "name" ? "asc" : "desc";
145
146 const after = query.cursor ? decodeCursor(query.cursor) : null;
147
148
149 const cv = after ? (col === "size" ? Number(after.sortValue) : after.sortValue) : null;
150
151 const files = await this.prisma.file.findMany({
152 where: {
153 workspaceId,
154 deletedAt: null,
155 ...(query.scope === "page" && query.pageId
156 ? { pageId: query.pageId }
157 : {}),
158 ...(query.status ? { status: query.status } : {}),
159 ...(query.search
160 ? { name: { contains: query.search, mode: "insensitive" as const } }
161 : {}),
162 ...(query.kind ? { mimeType: { in: mimesForKind(query.kind) } } : {}),
163 ...(after
164 ? {
165
166
167
168 OR: [
169 { [col]: dir === "asc" ? { gt: cv } : { lt: cv } },
170 { [col]: cv, id: dir === "asc" ? { gt: after.id } : { lt: after.id } },
171 ],
172 } : {}),
173 },
174 orderBy: [{ [col]: dir }, { id: dir }],
175 take: query.limit,
176 });
177
178
179
180 const uploaderIds = [...new Set(files.map((f) => f.uploadedBy))];
181 const pageIds = [...new Set(files.map((f) => f.pageId).filter(Boolean))] as string[];
182 const [users, pages, versionCounts] = await Promise.all([
183 this.prisma.user.findMany({
184 where: { id: { in: uploaderIds } },
185 select: { id: true, name: true },
186 }),
187 this.prisma.page.findMany({
188 where: { id: { in: pageIds } },
189 select: { id: true, title: true },
190 }),
191 this.prisma.fileVersion.groupBy({
192 by: ["fileId"],
193 where: { fileId: { in: files.map((f) => f.id) } },
194 _count: { _all: true },
195 }),
196 ]);
197 const userName = new Map(users.map((u) => [u.id, u.name]));
198 const pageName = new Map(pages.map((p) => [p.id, p.title]));
199 const versionCount = new Map(versionCounts.map((v) => [v.fileId, v._count._all]));
200
201 const last = files[files.length - 1];
202 const nextCursor =
203 files.length === query.limit && last
204 ? encodeCursor({
205 id: last.id,
206
207
208 sortValue:
209 col === "createdAt" || col === "updatedAt"
210 ? (last[col] as Date).toISOString()
211 : String(last[col])
212 })
213 : null;
214
215 return {
216 data: files.map((f) => ({
217 ...toFileRecord(f, versionCount.get(f.id) ?? 1),
218 uploadedByName: userName.get(f.uploadedBy) ?? null,
219 pageName: f.pageId ? (pageName.get(f.pageId) ?? null) : null,
220 versionCount: versionCount.get(f.id) ?? 1,
221 })),
222 nextCursor,
223 };
224 }
225
226
227 async rename(fileId: string, userId: string, input: RenameFileInput, req: Request) {
228 const file = await this.prisma.file.findUnique({ where: { id: fileId } });
229 if (!file || file.deletedAt) throw ApiError.notFound("File not found.");
230 await this.requireFileLevel(file, userId, "EDIT");
231 if (!isAllowedMime(file.mimeType)) {
232
233
234 }
235 const updated = await this.prisma.file.update({
236 where: { id: file.id },
237 data: { name: input.name },
238 });
239 await this.audit.audit(req, req.auth, {
240 action: "file.renamed",
241 resourceType: "file",
242 resourceId: file.id,
243 workspaceId: file.workspaceId,
244 metadata: { from: file.name, to: input.name },
245 });
246
247
248
249
250 await this.events.publish("file.updated", {
251 workspaceId: file.workspaceId,
252 fileId: file.id,
253 name: updated.name,
254 });
255 return toFileRecord(updated, 1);
256 }
257
258
259
260
261 async versions(fileId: string, userId: string, req: Request) {
262 const file = await this.prisma.file.findUnique({ where: { id: fileId } });
263 if (!file || file.deletedAt) throw ApiError.notFound("File not found.");
264 await this.requireFileLevel(file, userId, "VIEW");
265 const rows = await this.prisma.fileVersion.findMany({
266 where: { fileId },
267 orderBy: { version: "asc" },
268 include: { user: { select: { id: true, name: true } } },
269 });
270 const currentVersion = (await this.prisma.fileVersion.findFirst({
271 where: { fileId, key: file.key },
272 orderBy: { version: "desc" },
273 }))?.version ?? null;
274
275
276
277
278 return rows.map((v) => ({
279 id: v.id,
280 version: v.version,
281 size: v.size,
282 current: v.version === currentVersion,
283 createdBy: v.createdBy ?? null,
284 createdByName: v.user?.name ?? null,
285 createdAt: v.createdAt.toISOString(),
286 }));
287 }
288
289
296 async rollback(fileId: string, userId: string, input: RollbackFileInput, req: Request) {
297 const file = await this.prisma.file.findUnique({ where: { id: fileId } });
298 if (!file || file.deletedAt) throw ApiError.notFound("File not found.");
299 await this.requireFileLevel(file, userId, "EDIT");
300 const target = await this.prisma.fileVersion.findUnique({
301 where: { fileId_version: { fileId: file.id, version: input.version } },
302 });
303 if (!target) {
304 throw new ApiError("NOT_FOUND", 404, `Version ${input.version} does not exist for this file.`, [
305 { reason: "version_not_found", version: input.version },
306 ]);
307 }
308 const updated = await this.prisma.file.update({
309 where: { id: file.id },
310 data: { key: target.key, size: target.size },
311 });
312 await this.audit.audit(req, req.auth, {
313 action: "file.rolled_back",
314 resourceType: "file",
315 resourceId: file.id,
316 workspaceId: file.workspaceId,
317 metadata: { name: file.name, version: input.version, fromKey: file.key, toKey: target.key },
318 });
319 return toFileRecord(updated, input.version);
320 }
321
322
329 async deleteOldVersions(fileId: string, userId: string, req: Request) {
330 const file = await this.prisma.file.findUnique({ where: { id: fileId } });
331 if (!file || file.deletedAt) throw ApiError.notFound("File not found.");
332 await this.requireFileLevel(file, userId, "EDIT");
333 const s3 = makeFilesClient();
334 const s3Thumbs = makeThumbsClient();
335 const pruned = await this.prisma.$transaction(async (tx) => {
336 const rows = await tx.fileVersion.findMany({ where: { fileId: file.id } });
337
338
339 const keep = rows.filter((r) => r.key === file.key);
340 const drop = rows.filter((r) => r.key !== file.key);
341 for (const r of drop) {
342 await s3.deleteObject(r.key).catch(() => undefined);
343 }
344
345
346
347
348
349 if (drop.length > 0) {
350 const droppedBytes = BigInt(drop.reduce((sum, r) => sum + r.size, 0));
351 await tx.fileVersion.deleteMany({ where: { id: { in: drop.map((r) => r.id) } } });
352
353 await adjustWorkspaceStorageBytes(tx, file.workspaceId, -droppedBytes);
354 }
355 return drop.length;
356 });
357 if (pruned === 0) return { pruned: 0 };
358 await this.audit.audit(req, req.auth, {
359 action: "file.versions_pruned",
360 resourceType: "file",
361 resourceId: file.id,
362 workspaceId: file.workspaceId,
363 metadata: { name: file.name, pruned },
364 });
365 return { pruned };
366 }
367
368
369
370
377 async remove(fileId: string, userId: string, req: Request, purge = false) {
378 const file = await this.prisma.file.findUnique({ where: { id: fileId } });
379 if (!file) throw ApiError.notFound("File not found.");
380 if (purge) {
381
382
383 await this.purge(file, userId, req);
384 return { purged: true };
385 }
386 if (file.deletedAt) {
387 throw ApiError.conflict("File is already in the trash.", [{ reason: "already_trashed" }]);
388 }
389 await this.requireFileLevel(file, userId, "EDIT");
390 await this.prisma.file.update({ where: { id: file.id }, data: { deletedAt: new Date() } });
391 await this.audit.audit(req, req.auth, {
392 action: "file.deleted",
393 resourceType: "file",
394 resourceId: file.id,
395 workspaceId: file.workspaceId,
396 metadata: { name: file.name, size: file.size, key: file.key },
397 });
398 await this.events.publish("file.deleted", {
399 workspaceId: file.workspaceId,
400 fileId: file.id,
401 name: file.name,
402 size: file.size,
403 blockId: file.blockId,
404 pageId: file.pageId,
405 });
406 return { trashed: true, deletedAt: new Date().toISOString() };
407 }
408
409
415 async restore(fileId: string, userId: string, req: Request) {
416 const file = await this.prisma.file.findUnique({ where: { id: fileId } });
417 if (!file || !file.deletedAt) throw ApiError.notFound("File not found (or not in trash).");
418 await this.requireFileLevel(file, userId, "EDIT");
419 await this.prisma.file.update({ where: { id: file.id }, data: { deletedAt: null } });
420 await this.audit.audit(req, req.auth, {
421 action: "file.restored",
422 resourceType: "file",
423 resourceId: file.id,
424 workspaceId: file.workspaceId,
425 metadata: { name: file.name, size: file.size, key: file.key },
426 });
427 await this.events.publish("file.uploaded", {
428 workspaceId: file.workspaceId,
429 fileId: file.id,
430 name: file.name,
431 size: file.size,
432 mimeType: file.mimeType,
433 blockId: file.blockId,
434 pageId: file.pageId,
435 restored: true,
436 });
437 return toFileRecord(file, 1);
438 }
439
440
447 private async purge(file: { id: string; workspaceId: string; name: string; key: string; pageId: string | null }, userId: string, req: Request): Promise<void> {
448
449
450
451
452 const fileRow = await this.prisma.file.findUnique({ where: { id: file.id } });
453 if (!fileRow) throw ApiError.notFound("File not found (or already purged).");
454
455
456 const level = await actorLevel(this.prisma, {
457 workspaceId: fileRow.workspaceId,
458 userId,
459 resource: { fileId: fileRow.id, pageId: fileRow.pageId ?? undefined },
460 });
461 const isRoleAdmin =
462 actorIsAdminKey(fileRow.workspaceId) || (await this.workspaceRole(fileRow.workspaceId, userId));
463 if (level !== "FULL_ACCESS" && !isRoleAdmin) {
464 await this.audit.audit(req, req.auth, {
465 action: "file.purge_denied",
466 resourceType: "file",
467 resourceId: file.id,
468 workspaceId: fileRow.workspaceId,
469 metadata: { name: file.name, level, reason: "purge_requires_full_access_or_admin" },
470 });
471 throw new ApiError("FORBIDDEN", 403, "Purging requires FULL_ACCESS on the file or a workspace Admin/Owner role.", [
472 { reason: "purge_requires_full_access_or_admin", have: level },
473 ]);
474 }
475 const s3 = makeFilesClient();
476 const s3Thumbs = makeThumbsClient();
477 const workspaceId = fileRow.workspaceId;
478 const thumbPrefix = `thumbs/${workspaceId}/${file.id}/`;
479 let versionCount = 0;
480 await this.prisma.$transaction(async (tx) => {
481 const rows = await tx.fileVersion.findMany({ where: { fileId: file.id } });
482 versionCount = rows.length;
483 for (const r of rows) {
484 await s3.deleteObject(r.key).catch(() => undefined);
485 }
486
487
488
489
490 await deletePrefix(s3Thumbs, thumbPrefix).catch(() => 0);
491 await tx.fileVersion.deleteMany({ where: { fileId: file.id } });
492 await tx.file.delete({ where: { id: file.id } });
493
494 const purgedBytes = BigInt(rows.reduce((sum, r) => sum + r.size, 0));
495 if (purgedBytes > 0n) await adjustWorkspaceStorageBytes(tx, workspaceId, -purgedBytes);
496 });
497 await this.audit.audit(req, req.auth, {
498 action: "file.purged",
499 resourceType: "file",
500 resourceId: file.id,
501 workspaceId,
502 metadata: { name: file.name, key: fileRow.key, thumbPrefix, versions: versionCount },
503 });
504 }
505
506
507 async trashList(workspaceId: string, userId: string) {
508 await assertCan(this.prisma, { workspaceId, userId }, "VIEW");
509 const files = await this.prisma.file.findMany({
510 where: { workspaceId, deletedAt: { not: null } },
511 orderBy: { deletedAt: "desc" },
512 take: 200,
513 });
514 const uploaderIds = [...new Set(files.map((f) => f.uploadedBy))];
515 const users = await this.prisma.user.findMany({ where: { id: { in: uploaderIds } }, select: { id: true, name: true } });
516 const userName = new Map(users.map((u) => [u.id, u.name]));
517 return files.map((f) => ({
518 ...toFileRecord(f, 1),
519 deletedAt: f.deletedAt?.toISOString() ?? null,
520 uploadedByName: userName.get(f.uploadedBy) ?? null,
521 }));
522 }
523
524
525
526
527 async listPermissions(fileId: string, userId: string, req: Request) {
528 const file = await this.prisma.file.findUnique({ where: { id: fileId } });
529 if (!file || file.deletedAt) throw ApiError.notFound("File not found.");
530 await this.requireFileLevel(file, userId, "EDIT");
531 const grants = await this.prisma.permission.findMany({
532 where: { workspaceId: file.workspaceId, resourceType: "file", resourceId: file.id },
533 });
534 return this.hydrateGrants(file.workspaceId, grants);
535 }
536
537
543 async setPermissions(fileId: string, userId: string, input: SetPermissionsInput, req: Request) {
544 const file = await this.prisma.file.findUnique({ where: { id: fileId } });
545 if (!file || file.deletedAt) throw ApiError.notFound("File not found.");
546 await this.requireFileLevel(file, userId, "EDIT");
547 const wanted = new Set(input.grants.map((g) => `${g.granteeType}:${g.granteeId}:${g.level}`));
548 const existing = await this.prisma.permission.findMany({
549 where: { workspaceId: file.workspaceId, resourceType: "file", resourceId: file.id },
550 });
551 const existingSet = new Set(existing.map((g) => `${g.granteeType}:${g.granteeId}:${g.level}`));
552
553 for (const g of existing) {
554 if (!wanted.has(`${g.granteeType}:${g.granteeId}:${g.level}`)) {
555 await this.prisma.permission.delete({ where: { id: g.id } });
556 }
557 }
558
559 for (const g of input.grants) {
560 if (!existingSet.has(`${g.granteeType}:${g.granteeId}:${g.level}`)) {
561 await this.prisma.permission.upsert({
562 where: {
563 workspaceId_granteeType_granteeId_resourceType_resourceId: {
564 workspaceId: file.workspaceId,
565 granteeType: g.granteeType,
566 granteeId: g.granteeId,
567 resourceType: "file",
568 resourceId: file.id,
569 },
570 },
571 update: { level: g.level },
572 create: {
573 workspaceId: file.workspaceId,
574 granteeType: g.granteeType,
575 granteeId: g.granteeId,
576 resourceType: "file",
577 resourceId: file.id,
578 level: g.level,
579 },
580 });
581 }
582 }
583 await this.audit.audit(req, req.auth, {
584 action: "file.permission_changed",
585 resourceType: "file",
586 resourceId: file.id,
587 workspaceId: file.workspaceId,
588 metadata: { name: file.name, grants: input.grants },
589 });
590
591
592
593
594 if (wanted.size !== existingSet.size || [...wanted].some((k) => !existingSet.has(k))) {
595 await this.audit.audit(req, req.auth, {
596 action: "permission.changed",
597 resourceType: "file",
598 resourceId: file.id,
599 workspaceId: file.workspaceId,
600 metadata: { change: "file_grants", targetId: file.id, grants: input.grants },
601 });
602 }
603 await this.events.publish("permission.changed", {
604 workspaceId: file.workspaceId,
605 resourceType: "file",
606 resourceId: file.id,
607 userId,
608 });
609 return this.listPermissions(file.id, userId, req);
610 }
611
612
613
614
620 private async requireFileLevel(
621 file: { id: string; workspaceId: string; pageId: string | null },
622 userId: string,
623 need: Exclude<Level, "NONE">
624 ): Promise<Level> {
625 return assertCan(this.prisma, {
626 workspaceId: file.workspaceId,
627 userId,
628 resource: { fileId: file.id, pageId: file.pageId ?? undefined },
629 }, need);
630 }
631
632
633 private async workspaceRole(workspaceId: string, userId: string): Promise<boolean> {
634 const m = await this.prisma.workspaceMember.findUnique({
635 where: { workspaceId_userId: { workspaceId, userId } },
636 });
637 return !!m && (m.role === "ADMIN" || m.role === "OWNER");
638 }
639
640
641 private async hydrateGrants(
642 workspaceId: string,
643 grants: { id: string; granteeType: string; granteeId: string; level: string; inheritsFrom: string | null }[]
644 ) {
645 const userIds = grants.filter((g) => g.granteeType === "user").map((g) => g.granteeId);
646 const teamIds = grants.filter((g) => g.granteeType === "team").map((g) => g.granteeId);
647 const [users, teams] = await Promise.all([
648 userIds.length
649 ? this.prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true, email: true } })
650 : [],
651 teamIds.length
652 ? this.prisma.team.findMany({ where: { id: { in: teamIds } }, select: { id: true, name: true } })
653 : [],
654 ]);
655 const userName = new Map(users.map((u) => [u.id, u.name]));
656 const userEmail = new Map(users.map((u) => [u.id, u.email]));
657 const teamName = new Map(teams.map((t) => [t.id, t.name]));
658 return grants.map((g) => ({
659 id: g.id,
660 granteeType: g.granteeType,
661 granteeId: g.granteeId,
662 level: g.level,
663 inheritsFrom: g.inheritsFrom,
664 granteeName:
665 g.granteeType === "user" ? userName.get(g.granteeId) ?? g.granteeId
666 : g.granteeType === "team" ? teamName.get(g.granteeId) ?? g.granteeId
667 : g.granteeId,
668 granteeEmail: g.granteeType === "user" ? userEmail.get(g.granteeId) ?? null : null,
669 }));
670 }
671
672
680 private async requireReadable(
681 fileId: string,
682 userId: string,
683 req: Request,
684 auditDenied = false,
685 allowQuarantined = false,
686 ) {
687 const file = await this.prisma.file.findUnique({ where: { id: fileId } });
688 if (!file || file.deletedAt) return null;
689
690
691 if (file.status === "VIRUS_DETECTED" && !allowQuarantined) {
692 if (auditDenied) {
693 await this.audit.audit(req, req.auth, {
694 action: "file.download_denied",
695 resourceType: "file",
696 resourceId: file.id,
697 workspaceId: file.workspaceId,
698 metadata: { reason: "quarantined", name: file.name },
699 });
700 }
701 throw new ApiError("FILE_QUARANTINED", 403,
702 "This file was blocked by the virus scan and cannot be downloaded.",
703 [{ reason: "file_quarantined" }]);
704 }
705
706 try {
707
708
709
710
711
712
713 await assertCan(this.prisma, {
714 workspaceId: file.workspaceId,
715 userId,
716 resource: { fileId: file.id, pageId: file.pageId ?? undefined },
717 }, "VIEW");
718 return file;
719 } catch (err) {
720 if (auditDenied) {
721 await this.audit.audit(req, req.auth, {
722 action: "file.download_denied",
723 resourceType: "file",
724 resourceId: file.id,
725 workspaceId: file.workspaceId,
726 metadata: { reason: "no_view", name: file.name, path: req.originalUrl },
727 });
728 }
729 throw err;
730 }
731 }
732
733
734 private signedUrls(
735 file: { key: string; name: string; mimeType: string },
736 disposition: "attachment" | "inline" = "attachment",
737 ): { downloadUrl: string; previewUrl: string; expiresAt: string } {
738 const env = loadEnv();
739 const expiresSec = signExpirySeconds(env);
740 const now = new Date();
741 const config = filesBucketConfig(env);
742
743
744
745 const override = CONTENT_TYPE_OVERRIDES[file.mimeType];
746 const disp = (d: "attachment" | "inline") =>
747 `${override ? "attachment" : d}; filename="${escapeQuotes(file.name)}"`;
748
749 const dl = presignGet({
750 config,
751 key: file.key,
752 expiresSec,
753 now,
754 responseContentDisposition: disp("attachment"),
755 });
756 const pv = presignGet({
757 config,
758 key: file.key,
759 expiresSec,
760 now,
761 responseContentDisposition: disp("inline"),
762 });
763 return {
764 downloadUrl: dl.url,
765 previewUrl: pv.url,
766 expiresAt: new Date(now.getTime() + expiresSec * 1000).toISOString(),
767 };
768 }
769}
770
771function escapeQuotes(name: string): string {
772 return name.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
773}
774
775
776
777
778
779interface FileCursor {
780 id: string;
781 sortValue: string;
782}
783
784function encodeCursor(c: FileCursor): string {
785 return Buffer.from(JSON.stringify(c), "utf8").toString("base64url");
786}
787
788function decodeCursor(raw: string): FileCursor | null {
789 try {
790 const parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
791 if (
792 parsed &&
793 typeof parsed.id === "string" &&
794 typeof parsed.sortValue === "string" &&
795 parsed.id.length > 0
796 ) {
797 return parsed as FileCursor;
798 }
799 return null;
800 } catch {
801
802 return null;
803 }
804}
805
806const KIND_MIMES: Record<string, string[]> = {
807 image: ["image/png", "image/jpeg", "image/webp", "image/gif", "image/svg+xml"],
808 video: ["video/mp4", "video/webm", "video/quicktime"],
809 audio: ["audio/mpeg", "audio/wav", "audio/mp4", "audio/x-wav"],
810 pdf: ["application/pdf"],
811 doc: [
812 "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
813 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
814 "application/vnd.openxmlformats-officedocument.presentationml.presentation",
815 "text/markdown",
816 "text/plain",
817 "text/csv",
818 ],
819 other: ["application/zip", "application/octet-stream"],
820};
821
822function mimesForKind(kind: string): string[] {
823 return KIND_MIMES[kind] ?? [];
824}
825