@axiom-lattice/gateway 2.1.101 → 2.1.103

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.
@@ -0,0 +1,299 @@
1
+ import { FastifyRequest, FastifyReply } from "fastify";
2
+ import type { ResourceAddress, SharedResourceStore } from "@axiom-lattice/protocols";
3
+ import bcrypt from "bcryptjs";
4
+ import {
5
+ generateToken,
6
+ createSharePayload,
7
+ TokenCache,
8
+ SandboxLatticeManager,
9
+ } from "@axiom-lattice/core";
10
+ import { getContentTypeFromFilename } from "../utils/mime";
11
+
12
+ interface ResourceControllerDeps {
13
+ store: SharedResourceStore;
14
+ sandboxManager: SandboxLatticeManager;
15
+ cache: TokenCache;
16
+ logger: { info: (msg: string, obj?: object) => void; warn: (msg: string, obj?: object) => void; error: (msg: string, obj?: object) => void };
17
+ }
18
+
19
+ export class ResourceController {
20
+ constructor(private deps: ResourceControllerDeps) {}
21
+
22
+ async createShare(request: FastifyRequest, reply: FastifyReply) {
23
+ const userId = (request as any).user
24
+ ? (request as any).user.id ?? "unknown"
25
+ : "unknown";
26
+ const tenantId = (request.headers["x-tenant-id"] as string) || "default";
27
+ const workspaceId = request.headers["x-workspace-id"] as string;
28
+ const projectId = request.headers["x-project-id"] as string;
29
+ const body = request.body as any;
30
+
31
+ if (!workspaceId || !projectId) {
32
+ return reply.status(400).send({ error: "x-workspace-id and x-project-id headers required" });
33
+ }
34
+
35
+ const payload = createSharePayload(
36
+ tenantId,
37
+ workspaceId,
38
+ projectId,
39
+ userId as string,
40
+ body,
41
+ );
42
+ const token = generateToken();
43
+
44
+ try {
45
+ await this.deps.store.create({ ...payload, token });
46
+ this.deps.logger.info(
47
+ "[share] created",
48
+ { token, originalPath: body.resourcePath, normalizedPath: payload.address.resourcePath, volume: payload.address.volume, userId },
49
+ );
50
+ } catch {
51
+ return reply.status(500).send({ error: "Failed to create share" });
52
+ }
53
+
54
+ return reply.status(201).send({ token, url: `/s/${token}` });
55
+ }
56
+
57
+ async listShares(request: FastifyRequest, reply: FastifyReply) {
58
+ const userId = (request as any).user
59
+ ? (request as any).user.id ?? "unknown"
60
+ : "unknown";
61
+ const tenantId = (request.headers["x-tenant-id"] as string) || "default";
62
+ const shares = await this.deps.store.listByUser(tenantId, userId as string);
63
+ return reply.send(shares);
64
+ }
65
+
66
+ async updateShare(request: FastifyRequest, reply: FastifyReply) {
67
+ const { token } = request.params as unknown as { token: string };
68
+ const userId = (request as any).user
69
+ ? (request as any).user.id as string
70
+ : undefined;
71
+ const patch = request.body as any;
72
+
73
+ const record = await this.deps.store.findByToken(token);
74
+ if (!record || record.createdBy !== userId) {
75
+ return reply.status(404).send({ error: "Share not found" });
76
+ }
77
+
78
+ await this.deps.store.update(token, patch);
79
+
80
+ if (patch.revoked) {
81
+ this.deps.cache.invalidate(token);
82
+ }
83
+
84
+ return reply.send({ ok: true });
85
+ }
86
+
87
+ async revokeShare(request: FastifyRequest, reply: FastifyReply) {
88
+ const { token } = request.params as unknown as { token: string };
89
+ const userId = (request as any).user
90
+ ? (request as any).user.id as string
91
+ : undefined;
92
+
93
+ const record = await this.deps.store.findByToken(token);
94
+ if (!record || record.createdBy !== userId) {
95
+ return reply.status(404).send({ error: "Share not found" });
96
+ }
97
+
98
+ await this.deps.store.update(token, { revoked: true });
99
+ this.deps.cache.invalidate(token);
100
+
101
+ return reply.send({ ok: true });
102
+ }
103
+
104
+ async resolveResource(request: FastifyRequest, reply: FastifyReply) {
105
+ const params = request.params as unknown as { token: string; "*"?: string };
106
+ const { token } = params;
107
+ const subPath = params["*"] ?? "";
108
+
109
+ this.deps.logger.info("[share] resolve start", { token, subPath });
110
+
111
+ // 1. Check cache
112
+ const cached = this.deps.cache.get(token);
113
+ if (cached && !cached.requiresUnlock) {
114
+ this.deps.logger.info("[share] cache hit", { token, volume: cached.address.volume, resourcePath: cached.address.resourcePath });
115
+ return this._serveFile(cached.address, token, subPath, cached.sandboxConfig ?? { tenantId: "default", workspaceId: "", projectId: "", assistantId: null }, reply);
116
+ }
117
+ this.deps.logger.info("[share] cache miss, querying DB", { token });
118
+
119
+ // 2. Single DB lookup
120
+ const record = await this.deps.store.findByToken(token);
121
+ if (!record || record.revoked) {
122
+ this.deps.logger.warn("[share] token not found or revoked", { token, found: !!record, revoked: record?.revoked });
123
+ return reply.status(404).send("Not found");
124
+ }
125
+ if (record.expiresAt && new Date(record.expiresAt) < new Date()) {
126
+ this.deps.logger.info("[share] token expired", { token, expiresAt: record.expiresAt });
127
+ return reply.status(410).send("Expired");
128
+ }
129
+
130
+ this.deps.logger.info("[share] record found", {
131
+ token,
132
+ volume: record.address.volume,
133
+ resourcePath: record.address.resourcePath,
134
+ visibility: record.visibility,
135
+ workspaceId: record.workspaceId,
136
+ projectId: record.projectId,
137
+ });
138
+
139
+ // 3. Auth
140
+ const isInternal = this._isInternalRequest(request);
141
+ if (!isInternal) {
142
+ if (record.visibility === "password") {
143
+ const unlocked = this._isUnlocked(request, token);
144
+ if (!unlocked) {
145
+ this.deps.logger.info("[share] password protected, returning password page", { token });
146
+ this.deps.cache.set(token, {
147
+ address: record.address,
148
+ requiresUnlock: true,
149
+ sandboxConfig: { tenantId: record.tenantId, workspaceId: record.workspaceId, projectId: record.projectId, assistantId: record.assistantId },
150
+ });
151
+ return reply.type("text/html").send(this._passwordPage(token));
152
+ }
153
+ this.deps.logger.info("[share] password unlocked", { token });
154
+ }
155
+ }
156
+
157
+ // 4. Access count
158
+ if (record.maxAccess !== null) {
159
+ const ok = await this.deps.store.atomicIncrementAccess(token);
160
+ if (!ok) return reply.status(410).send("Access limit reached");
161
+ } else {
162
+ this.deps.store.incrementAccess(token).catch(() => {});
163
+ }
164
+
165
+ // 5. Cache
166
+ const sandboxConfig = { tenantId: record.tenantId, workspaceId: record.workspaceId, projectId: record.projectId, assistantId: record.assistantId };
167
+ this.deps.cache.set(token, {
168
+ address: record.address,
169
+ requiresUnlock: record.visibility === "password" && !isInternal,
170
+ sandboxConfig,
171
+ });
172
+
173
+ return this._serveFile(record.address, token, subPath, sandboxConfig, reply);
174
+ }
175
+
176
+ async unlockShare(request: FastifyRequest, reply: FastifyReply) {
177
+ const { token } = request.params as unknown as { token: string };
178
+ const { password } = request.body as unknown as { password: string };
179
+
180
+ const record = await this.deps.store.findByToken(token);
181
+ if (!record || !record.passwordHash) {
182
+ return reply.status(404).send({ error: "Invalid request" });
183
+ }
184
+
185
+ // Plain text comparison for now — bcrypt will come in Task 12
186
+ const valid = await bcrypt.compare(password, record.passwordHash!);
187
+ if (!valid) {
188
+ return reply.status(401).send({ error: "Incorrect password" });
189
+ }
190
+
191
+ reply.header(
192
+ "Set-Cookie",
193
+ `share_unlock_${token}=1; Max-Age=86400; Path=/s/${token}; SameSite=Lax; HttpOnly; Secure`,
194
+ );
195
+ return reply.send({ ok: true });
196
+ }
197
+
198
+ private _isInternalRequest(request: FastifyRequest): boolean {
199
+ // Check for valid user session from auth middleware
200
+ const user = (request as any).user;
201
+ return !!user && !!user.id;
202
+ }
203
+
204
+ private _isUnlocked(request: FastifyRequest, token: string): boolean {
205
+ const cookie = request.headers.cookie ?? "";
206
+ return cookie.includes(`share_unlock_${token}=1`);
207
+ }
208
+
209
+ private async _serveFile(
210
+ address: ResourceAddress,
211
+ token: string,
212
+ subPath: string,
213
+ sandboxConfig: { tenantId: string; workspaceId: string; projectId: string; assistantId: string | null },
214
+ reply: FastifyReply,
215
+ ) {
216
+ const provider = this.deps.sandboxManager.getDefaultProvider();
217
+ const resolver = provider.getResourceResolver();
218
+ const sandboxPath = subPath
219
+ ? this._resolveSafeSubPath(address.resourcePath, subPath)
220
+ : address.resourcePath;
221
+
222
+ this.deps.logger.info("[share] serving file", {
223
+ token, volume: address.volume, sandboxPath, subPath: subPath || "(none)",
224
+ tenantId: sandboxConfig.tenantId, workspaceId: sandboxConfig.workspaceId, projectId: sandboxConfig.projectId,
225
+ });
226
+
227
+ let buf: Buffer;
228
+ try {
229
+ // Try volume FS first
230
+ buf = await resolver.resolve({ ...address, resourcePath: sandboxPath });
231
+ this.deps.logger.info("[share] resolved via volume FS", { token, size: buf.length });
232
+ } catch (err) {
233
+ this.deps.logger.warn("[share] volume FS failed, trying sandbox fallback", {
234
+ token, path: sandboxPath, error: (err as Error).message,
235
+ });
236
+ try {
237
+ const sandbox = await this.deps.sandboxManager.getSandboxFromConfig({
238
+ assistant_id: sandboxConfig.assistantId ?? "",
239
+ thread_id: "",
240
+ tenantId: sandboxConfig.tenantId,
241
+ workspaceId: sandboxConfig.workspaceId,
242
+ projectId: sandboxConfig.projectId,
243
+ });
244
+ buf = await sandbox.file.downloadFile({ file: `/project/${sandboxPath}` });
245
+ this.deps.logger.info("[share] resolved via sandbox fallback", { token, size: buf.length });
246
+ } catch (err) {
247
+ this.deps.logger.warn("[share] all resolution attempts failed", { token, resourcePath: sandboxPath, error: (err as Error).message });
248
+ return reply.status(404).send("File not found");
249
+ }
250
+ }
251
+
252
+ const fullPath = sandboxPath;
253
+
254
+ const filename = fullPath.split("/").pop() || "download";
255
+ const isHtml = !subPath && /\.(html|htm)$/i.test(filename);
256
+
257
+ if (isHtml) {
258
+ buf = this._injectBaseTag(buf, token);
259
+ }
260
+
261
+ const contentType = getContentTypeFromFilename(filename);
262
+
263
+ return reply
264
+ .status(200)
265
+ .type(contentType)
266
+ .header("Content-Disposition", `inline; filename="${filename}"`)
267
+ .header("Access-Control-Allow-Origin", "*")
268
+ .send(buf);
269
+ }
270
+
271
+ private _resolveSafeSubPath(entryFile: string, subPath: string): string {
272
+ if (!subPath) return entryFile;
273
+ if (subPath.includes("..")) throw new Error("Path traversal denied");
274
+ const entryDir = entryFile.replace(/[^/]+$/, "");
275
+ return (entryDir + subPath).replace(/\/+/g, "/");
276
+ }
277
+
278
+ private _injectBaseTag(buf: Buffer, token: string): Buffer {
279
+ const html = buf.toString("utf-8");
280
+ if (/<base\b/i.test(html)) return buf;
281
+ const baseTag = `<base href="/s/${token}/">`;
282
+ if (html.includes("</head>")) {
283
+ return Buffer.from(html.replace("</head>", `${baseTag}</head>`), "utf-8");
284
+ }
285
+ // No </head> — insert after <head> or <html> or after <!doctype>
286
+ if (html.includes("<head>")) {
287
+ return Buffer.from(html.replace("<head>", `<head>${baseTag}`), "utf-8");
288
+ }
289
+ if (html.includes("<html>")) {
290
+ return Buffer.from(html.replace("<html>", `<html>${baseTag}`), "utf-8");
291
+ }
292
+ // Fallback: prepend (will be after <!doctype> if present since html starts with <!doctype)
293
+ return Buffer.from(baseTag + html, "utf-8");
294
+ }
295
+
296
+ private _passwordPage(token: string): string {
297
+ return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Password Protected</title><style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f5f5f5}.card{background:#fff;padding:32px;border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,.1);width:100%;max-width:360px;text-align:center}h2{margin:0 0 8px;font-size:20px}p{margin:0 0 20px;color:#666;font-size:14px}input{width:100%;padding:10px 12px;border:1px solid #d9d9d9;border-radius:8px;font-size:14px;box-sizing:border-box;margin-bottom:12px}button{width:100%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:8px;font-size:14px;cursor:pointer}.error{color:#ef4444;font-size:13px;margin-top:8px;display:none}</style></head><body><div class="card"><h2>Password Protected</h2><p>This share requires a password to access.</p><form id="f"><input type="password" id="p" placeholder="Enter password" required><button type="submit">Unlock</button><div class="error" id="e">Incorrect password</div></form></div><script>document.getElementById('f').onsubmit=async(e)=>{e.preventDefault();const r=await fetch('/s/${token}/unlock',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:document.getElementById('p').value})});if(r.ok)location.reload();else document.getElementById('e').style.display='block'}</script></body></html>`;
298
+ }
299
+ }
@@ -1,36 +1,7 @@
1
1
  import { FastifyInstance } from "fastify";
2
2
  import { sandboxService } from "../services/sandbox_service";
3
3
  import { getSandBoxManager } from "@axiom-lattice/core";
4
-
5
- /** Get filename from path (e.g. /home/gem/uploads/foo.pdf -> foo.pdf) */
6
- function getFilenameFromPath(path: string): string {
7
- const segments = path.replace(/\/+$/, "").split("/");
8
- return segments[segments.length - 1] || "download";
9
- }
10
-
11
- /** Common extension -> MIME type for download response */
12
- const EXT_TO_MIME: Record<string, string> = {
13
- ".txt": "text/plain",
14
- ".html": "text/html",
15
- ".css": "text/css",
16
- ".js": "application/javascript",
17
- ".json": "application/json",
18
- ".pdf": "application/pdf",
19
- ".png": "image/png",
20
- ".jpg": "image/jpeg",
21
- ".jpeg": "image/jpeg",
22
- ".gif": "image/gif",
23
- ".webp": "image/webp",
24
- ".svg": "image/svg+xml",
25
- ".zip": "application/zip",
26
- ".csv": "text/csv",
27
- ".xml": "application/xml",
28
- };
29
-
30
- function getContentTypeFromFilename(filename: string): string {
31
- const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".")).toLowerCase() : "";
32
- return EXT_TO_MIME[ext] ?? "application/octet-stream";
33
- }
4
+ import { getContentTypeFromFilename, getFilenameFromPath } from "../utils/mime";
34
5
 
35
6
  interface SandboxParams {
36
7
  assistantId: string;
@@ -12,6 +12,7 @@ import { getStoreLattice } from "@axiom-lattice/core";
12
12
  import { SandboxFilesystem, FilesystemBackend } from "@axiom-lattice/core";
13
13
  import { getSandBoxManager } from "@axiom-lattice/core";
14
14
  import { v4 as uuidv4 } from "uuid";
15
+ import { getContentTypeFromFilename, getFilenameFromPath } from "../utils/mime";
15
16
  import type {
16
17
  CreateWorkspaceRequest,
17
18
  UpdateWorkspaceRequest,
@@ -301,34 +302,6 @@ export class WorkspaceController {
301
302
  }
302
303
  }
303
304
 
304
- private getFilenameFromPath(filePath: string): string {
305
- const segments = filePath.split("/");
306
- return segments[segments.length - 1] || "download";
307
- }
308
-
309
- private getMimeType(filename: string): string {
310
- const ext = filename.split(".").pop()?.toLowerCase() || "";
311
- const mimeTypes: Record<string, string> = {
312
- pdf: "application/pdf",
313
- csv: "text/csv",
314
- xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
315
- xls: "application/vnd.ms-excel",
316
- txt: "text/plain",
317
- json: "application/json",
318
- png: "image/png",
319
- jpg: "image/jpeg",
320
- jpeg: "image/jpeg",
321
- gif: "image/gif",
322
- svg: "image/svg+xml",
323
- html: "text/html",
324
- htm: "text/html",
325
- mp3: "audio/mpeg",
326
- mp4: "video/mp4",
327
- webm: "video/webm",
328
- };
329
- return mimeTypes[ext] || "application/octet-stream";
330
- }
331
-
332
305
  private isBinaryContentType(filename: string): boolean {
333
306
  const ext = filename.split(".").pop()?.toLowerCase() || "";
334
307
  const binaryExtensions = new Set([
@@ -370,7 +343,7 @@ export class WorkspaceController {
370
343
  projectId,
371
344
  };
372
345
 
373
- const filename = this.getFilenameFromPath(resolvedPath);
346
+ const filename = getFilenameFromPath(resolvedPath);
374
347
  const isBinary = this.isBinaryContentType(filename);
375
348
 
376
349
  // Use volume backend when available; fall back to sandbox
@@ -389,7 +362,7 @@ export class WorkspaceController {
389
362
  buf = await sandbox.file.downloadFile({ file: resolvedPath });
390
363
  }
391
364
 
392
- const inferredContentType = this.getMimeType(filename);
365
+ const inferredContentType = getContentTypeFromFilename(filename);
393
366
 
394
367
  const contentDisposition = `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`;
395
368
  return reply.status(200).type(inferredContentType).header("Content-Disposition", contentDisposition).send(buf);
@@ -397,8 +370,8 @@ export class WorkspaceController {
397
370
 
398
371
  const { backend } = await this.getBackend(tenantId, workspaceId, projectId, assistantId, resolvedPath);
399
372
  const content = await backend.read(resolvedPath, 0, Infinity);
400
- const filename = this.getFilenameFromPath(resolvedPath);
401
- const mimeType = this.getMimeType(filename);
373
+ const filename = getFilenameFromPath(resolvedPath);
374
+ const mimeType = getContentTypeFromFilename(filename);
402
375
  const buffer = Buffer.from(content, "utf-8");
403
376
 
404
377
  return reply
@@ -446,7 +419,7 @@ export class WorkspaceController {
446
419
  projectId,
447
420
  };
448
421
 
449
- const filename = this.getFilenameFromPath(resolvedPath);
422
+ const filename = getFilenameFromPath(resolvedPath);
450
423
  const isBinary = this.isBinaryContentType(filename);
451
424
 
452
425
  // Use volume backend when available; fall back to sandbox
@@ -465,7 +438,7 @@ export class WorkspaceController {
465
438
  buf = await sandbox.file.downloadFile({ file: resolvedPath });
466
439
  }
467
440
 
468
- const inferredContentType = this.getMimeType(filename);
441
+ const inferredContentType = getContentTypeFromFilename(filename);
469
442
 
470
443
  try {
471
444
  const contentType = inferredContentType;
@@ -510,8 +483,8 @@ export class WorkspaceController {
510
483
 
511
484
  const { backend } = await this.getBackend(tenantId, workspaceId, projectId, assistantId, resolvedPath);
512
485
  const content = await backend.read(resolvedPath, 0, Infinity);
513
- const filename = this.getFilenameFromPath(resolvedPath);
514
- const mimeType = this.getMimeType(filename);
486
+ const filename = getFilenameFromPath(resolvedPath);
487
+ const mimeType = getContentTypeFromFilename(filename);
515
488
 
516
489
  // Inject AI2APP context script for HTML files
517
490
  let finalContent = content;
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import staticPlugin from "@fastify/static";
7
7
  import path from "path";
8
8
  import { fileURLToPath } from "url";
9
9
  import { registerLatticeRoutes } from "./routes";
10
+ import { registerResourceRoutes } from "./routes/resource-routes";
10
11
  import type { ChannelInstallationStore, BindingRegistry } from "@axiom-lattice/protocols";
11
12
  import { MessageRouter } from "./router/MessageRouter";
12
13
  import { ChannelAdapterRegistry } from "./channels/registry";
@@ -30,6 +31,7 @@ import {
30
31
  agentInstanceManager,
31
32
  createSandboxProvider,
32
33
  type CreateSandboxProviderConfig,
34
+ TokenCache,
33
35
  } from "@axiom-lattice/core";
34
36
  import {
35
37
  LoggerType,
@@ -126,6 +128,9 @@ app.addHook("preHandler", async (request, reply) => {
126
128
  // Public routes don't require authentication
127
129
  if (PUBLIC_ROUTES.some((r) => request.url === r)) return;
128
130
 
131
+ // Public resource shares — /s/:token and /s/:token/*
132
+ if (request.url.startsWith("/s/")) return;
133
+
129
134
  // Allow direct URL access for iframe endpoints (viewfile, downloadfile)
130
135
  // These carry tenant context via query params and are accessed by iframes
131
136
  const urlPath = request.url.split("?")[0];
@@ -351,6 +356,25 @@ const start = async (config?: LatticeGatewayConfig) => {
351
356
  logger.info("Registered sandbox manager from env configuration");
352
357
  }
353
358
 
359
+ // Initialize resource share controller
360
+ try {
361
+ const { ResourceController } = await import("./controllers/resources");
362
+ const sharedResourceStore = getStoreLattice("default", "sharedResource").store;
363
+ const cache = new TokenCache();
364
+ const resourceController = new ResourceController({
365
+ store: sharedResourceStore,
366
+ sandboxManager: sandboxLatticeManager,
367
+ cache,
368
+ logger,
369
+ });
370
+ registerResourceRoutes(app, resourceController);
371
+ logger.info("Resource share routes registered");
372
+ } catch (err) {
373
+ logger.warn("Resource share infrastructure unavailable", {
374
+ error: err instanceof Error ? err.message : String(err),
375
+ });
376
+ }
377
+
354
378
  // Connect all enabled channel installations
355
379
  if (channelDeps && process.env.CHANNELS_ENABLED !== "false") {
356
380
  const { connectAllChannels } = await import("@axiom-lattice/core");
@@ -0,0 +1,24 @@
1
+ import { FastifyInstance } from "fastify";
2
+ import { ResourceController } from "../controllers/resources";
3
+
4
+ export function registerResourceRoutes(
5
+ app: FastifyInstance,
6
+ controller: ResourceController
7
+ ) {
8
+ // Public share access — MUST be before /api/** wildcard routes
9
+ app.get("/s/:token", controller.resolveResource.bind(controller));
10
+ app.get("/s/:token/*", controller.resolveResource.bind(controller));
11
+ app.post("/s/:token/unlock", controller.unlockShare.bind(controller));
12
+ app.options("/s/:token", async (_req, reply) => {
13
+ reply.header("Access-Control-Allow-Origin", "*");
14
+ reply.header("Access-Control-Allow-Methods", "GET, OPTIONS, POST");
15
+ reply.header("Access-Control-Allow-Headers", "Content-Type");
16
+ return reply.status(204).send();
17
+ });
18
+
19
+ // Management API
20
+ app.post("/api/resources/share", controller.createShare.bind(controller));
21
+ app.get("/api/resources/shares", controller.listShares.bind(controller));
22
+ app.patch("/api/resources/share/:token", controller.updateShare.bind(controller));
23
+ app.delete("/api/resources/share/:token", controller.revokeShare.bind(controller));
24
+ }
@@ -0,0 +1,43 @@
1
+ const EXT_TO_MIME: Record<string, string> = {
2
+ ".txt": "text/plain",
3
+ ".html": "text/html",
4
+ ".htm": "text/html",
5
+ ".css": "text/css",
6
+ ".js": "application/javascript",
7
+ ".mjs": "application/javascript",
8
+ ".json": "application/json",
9
+ ".pdf": "application/pdf",
10
+ ".png": "image/png",
11
+ ".jpg": "image/jpeg",
12
+ ".jpeg": "image/jpeg",
13
+ ".gif": "image/gif",
14
+ ".webp": "image/webp",
15
+ ".svg": "image/svg+xml",
16
+ ".ico": "image/x-icon",
17
+ ".zip": "application/zip",
18
+ ".csv": "text/csv",
19
+ ".xml": "application/xml",
20
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
21
+ ".xls": "application/vnd.ms-excel",
22
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
23
+ ".ppt": "application/vnd.ms-powerpoint",
24
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
25
+ ".doc": "application/msword",
26
+ ".mp4": "video/mp4",
27
+ ".webm": "video/webm",
28
+ ".mp3": "audio/mpeg",
29
+ ".wav": "audio/wav",
30
+ ".ogg": "audio/ogg",
31
+ };
32
+
33
+ export function getContentTypeFromFilename(filename: string): string {
34
+ const ext = filename.includes(".")
35
+ ? filename.slice(filename.lastIndexOf(".")).toLowerCase()
36
+ : "";
37
+ return EXT_TO_MIME[ext] ?? "application/octet-stream";
38
+ }
39
+
40
+ export function getFilenameFromPath(path: string): string {
41
+ const segments = path.replace(/\/+$/, "").split("/");
42
+ return segments[segments.length - 1] || "download";
43
+ }