@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.
- package/.turbo/turbo-build.log +22 -18
- package/CHANGELOG.md +24 -0
- package/dist/chunk-R4ZO3HZ3.mjs +46 -0
- package/dist/chunk-R4ZO3HZ3.mjs.map +1 -0
- package/dist/index.js +339 -72
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +49 -60
- package/dist/index.mjs.map +1 -1
- package/dist/resources-NE6DFF5I.mjs +233 -0
- package/dist/resources-NE6DFF5I.mjs.map +1 -0
- package/package.json +11 -8
- package/src/controllers/__tests__/mime.test.ts +74 -0
- package/src/controllers/__tests__/resources.test.ts +99 -0
- package/src/controllers/resources.ts +299 -0
- package/src/controllers/sandbox.ts +1 -30
- package/src/controllers/workspace.ts +9 -36
- package/src/index.ts +24 -0
- package/src/routes/resource-routes.ts +24 -0
- package/src/utils/mime.ts +43 -0
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
401
|
-
const mimeType =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
514
|
-
const mimeType =
|
|
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
|
+
}
|