@pablopunk/webr 0.3.8 → 0.3.9
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/README.md +1 -1
- package/dist/client/_astro/App.BtZuijDn.js +4 -0
- package/dist/client/_astro/{App.B7RjqN_I.css → App.CJakfIju.css} +1 -1
- package/dist/server/chunks/{App_DDOnT1vx.mjs → App_C1bSzRXS.mjs} +205 -43
- package/dist/server/chunks/{_id__CRXKeG9Q.mjs → _id__PkkT8hXq.mjs} +1 -1
- package/dist/server/chunks/{_tab__1TQX6GGq.mjs → _tab__OowoBszk.mjs} +1 -1
- package/dist/server/chunks/{new_CBpgizdT.mjs → new_DC57I9zU.mjs} +1 -1
- package/dist/server/chunks/{settings_BahL_Diu.mjs → settings_BIpDm2u1.mjs} +1 -1
- package/dist/server/entry.mjs +5 -5
- package/package.json +1 -1
- package/src/lib/machines.ts +5 -2
- package/src/server/host.ts +19 -4
- package/src/server/runtime/manager.ts +25 -3
- package/src/server/runtime/reconcile.ts +10 -1
- package/src/server/runtime/target.ts +2 -1
- package/src/server/storage/database.ts +1 -0
- package/src/server/transport/herdr-target.ts +16 -3
- package/src/server/transport/icons.ts +12 -4
- package/src/server/uploads.ts +15 -4
- package/dist/client/_astro/App.CuhTptJe.js +0 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { S as createAstro, V as __exportAll, i as renderComponent, u as renderTemplate } from "./server_dfdLOyV2.mjs";
|
|
2
2
|
import { t as createComponent } from "./compiler_BnrQvXXD.mjs";
|
|
3
|
-
import { n as $$Base, t as App } from "./
|
|
3
|
+
import { n as $$Base, t as App } from "./App_C1bSzRXS.mjs";
|
|
4
4
|
//#region src/pages/sessions/[session]/tabs/[tab].astro
|
|
5
5
|
var _tab__exports = /* @__PURE__ */ __exportAll({
|
|
6
6
|
default: () => $$Tab,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { S as createAstro, V as __exportAll, i as renderComponent, u as renderTemplate } from "./server_dfdLOyV2.mjs";
|
|
2
2
|
import { t as createComponent } from "./compiler_BnrQvXXD.mjs";
|
|
3
|
-
import { n as $$Base, t as App } from "./
|
|
3
|
+
import { n as $$Base, t as App } from "./App_C1bSzRXS.mjs";
|
|
4
4
|
//#region src/pages/new.astro
|
|
5
5
|
var new_exports = /* @__PURE__ */ __exportAll({
|
|
6
6
|
default: () => $$New,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { S as createAstro, V as __exportAll, i as renderComponent, u as renderTemplate } from "./server_dfdLOyV2.mjs";
|
|
2
2
|
import { t as createComponent } from "./compiler_BnrQvXXD.mjs";
|
|
3
|
-
import { n as $$Base, t as App } from "./
|
|
3
|
+
import { n as $$Base, t as App } from "./App_C1bSzRXS.mjs";
|
|
4
4
|
//#region src/pages/settings.astro
|
|
5
5
|
var settings_exports = /* @__PURE__ */ __exportAll({
|
|
6
6
|
default: () => $$Settings,
|
package/dist/server/entry.mjs
CHANGED
|
@@ -2426,10 +2426,10 @@ var renderers = [Object.assign({
|
|
|
2426
2426
|
//#endregion
|
|
2427
2427
|
//#region \0virtual:astro:pages
|
|
2428
2428
|
var _page0 = () => import("./chunks/node_C8FqXy_n.mjs").then((n) => n.t);
|
|
2429
|
-
var _page1 = () => import("./chunks/
|
|
2430
|
-
var _page2 = () => import("./chunks/
|
|
2431
|
-
var _page3 = () => import("./chunks/
|
|
2432
|
-
var _page4 = () => import("./chunks/
|
|
2429
|
+
var _page1 = () => import("./chunks/new_DC57I9zU.mjs");
|
|
2430
|
+
var _page2 = () => import("./chunks/_tab__OowoBszk.mjs");
|
|
2431
|
+
var _page3 = () => import("./chunks/settings_BIpDm2u1.mjs");
|
|
2432
|
+
var _page4 = () => import("./chunks/_id__PkkT8hXq.mjs");
|
|
2433
2433
|
var _page5 = () => import("./chunks/index_Bvv1gpI0.mjs");
|
|
2434
2434
|
var pageMap = /* @__PURE__ */ new Map([
|
|
2435
2435
|
["node_modules/.pnpm/astro@7.3.5_@types+node@22.20.4_tsx@4.23.15_yaml@2.9.1/node_modules/astro/dist/assets/endpoint/node.js", _page0],
|
|
@@ -2441,7 +2441,7 @@ var pageMap = /* @__PURE__ */ new Map([
|
|
|
2441
2441
|
]);
|
|
2442
2442
|
//#endregion
|
|
2443
2443
|
//#region \0virtual:astro:manifest
|
|
2444
|
-
var _manifest = deserializeManifest({"rootDir":"file:///home/runner/work/webr/webr/","cacheDir":"file:///home/runner/work/webr/webr/node_modules/.astro/","outDir":"file:///home/runner/work/webr/webr/dist/","srcDir":"file:///home/runner/work/webr/webr/src/","publicDir":"file:///home/runner/work/webr/webr/public/","buildClientDir":"file:///home/runner/work/webr/webr/dist/client/","buildServerDir":"file:///home/runner/work/webr/webr/dist/server/","adapterName":"@astrojs/node","assetsDir":"_astro","routes":[{"file":"","links":[],"scripts":[],"styles":[],"routeData":{"type":"page","component":"_server-islands.astro","params":["name"],"segments":[[{"content":"_server-islands","dynamic":false,"spread":false}],[{"content":"name","dynamic":true,"spread":false}]],"pattern":"^\\/_server-islands\\/([^/]+?)\\/?$","prerender":false,"isIndex":false,"fallbackRoutes":[],"route":"/_server-islands/[name]","origin":"internal","distURL":[],"_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[],"routeData":{"route":"/_image","component":"node_modules/.pnpm/astro@7.3.5_@types+node@22.20.4_tsx@4.23.15_yaml@2.9.1/node_modules/astro/dist/assets/endpoint/node.js","params":[],"pathname":"/_image","pattern":"^\\/_image\\/?$","segments":[[{"content":"_image","dynamic":false,"spread":false}]],"type":"endpoint","prerender":false,"fallbackRoutes":[],"distURL":[],"isIndex":false,"origin":"internal","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[{"type":"external","src":"_astro/App.
|
|
2444
|
+
var _manifest = deserializeManifest({"rootDir":"file:///home/runner/work/webr/webr/","cacheDir":"file:///home/runner/work/webr/webr/node_modules/.astro/","outDir":"file:///home/runner/work/webr/webr/dist/","srcDir":"file:///home/runner/work/webr/webr/src/","publicDir":"file:///home/runner/work/webr/webr/public/","buildClientDir":"file:///home/runner/work/webr/webr/dist/client/","buildServerDir":"file:///home/runner/work/webr/webr/dist/server/","adapterName":"@astrojs/node","assetsDir":"_astro","routes":[{"file":"","links":[],"scripts":[],"styles":[],"routeData":{"type":"page","component":"_server-islands.astro","params":["name"],"segments":[[{"content":"_server-islands","dynamic":false,"spread":false}],[{"content":"name","dynamic":true,"spread":false}]],"pattern":"^\\/_server-islands\\/([^/]+?)\\/?$","prerender":false,"isIndex":false,"fallbackRoutes":[],"route":"/_server-islands/[name]","origin":"internal","distURL":[],"_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[],"routeData":{"route":"/_image","component":"node_modules/.pnpm/astro@7.3.5_@types+node@22.20.4_tsx@4.23.15_yaml@2.9.1/node_modules/astro/dist/assets/endpoint/node.js","params":[],"pathname":"/_image","pattern":"^\\/_image\\/?$","segments":[[{"content":"_image","dynamic":false,"spread":false}]],"type":"endpoint","prerender":false,"fallbackRoutes":[],"distURL":[],"isIndex":false,"origin":"internal","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[{"type":"external","src":"_astro/App.CJakfIju.css"}],"routeData":{"route":"/new","isIndex":false,"type":"page","pattern":"^\\/new\\/?$","segments":[[{"content":"new","dynamic":false,"spread":false}]],"params":[],"component":"src/pages/new.astro","pathname":"/new","prerender":false,"fallbackRoutes":[],"distURL":[],"origin":"project","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[{"type":"external","src":"_astro/App.CJakfIju.css"}],"routeData":{"route":"/sessions/[session]/tabs/[tab]","isIndex":false,"type":"page","pattern":"^\\/sessions\\/([^/]+?)\\/tabs\\/([^/]+?)\\/?$","segments":[[{"content":"sessions","dynamic":false,"spread":false}],[{"content":"session","dynamic":true,"spread":false}],[{"content":"tabs","dynamic":false,"spread":false}],[{"content":"tab","dynamic":true,"spread":false}]],"params":["session","tab"],"component":"src/pages/sessions/[session]/tabs/[tab].astro","prerender":false,"fallbackRoutes":[],"distURL":[],"origin":"project","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[{"type":"external","src":"_astro/App.CJakfIju.css"}],"routeData":{"route":"/settings","isIndex":false,"type":"page","pattern":"^\\/settings\\/?$","segments":[[{"content":"settings","dynamic":false,"spread":false}]],"params":[],"component":"src/pages/settings.astro","pathname":"/settings","prerender":false,"fallbackRoutes":[],"distURL":[],"origin":"project","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[{"type":"external","src":"_astro/App.CJakfIju.css"}],"routeData":{"route":"/threads/[id]","isIndex":false,"type":"page","pattern":"^\\/threads\\/([^/]+?)\\/?$","segments":[[{"content":"threads","dynamic":false,"spread":false}],[{"content":"id","dynamic":true,"spread":false}]],"params":["id"],"component":"src/pages/threads/[id].astro","prerender":false,"fallbackRoutes":[],"distURL":[],"origin":"project","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[],"routeData":{"route":"/","isIndex":true,"type":"page","pattern":"^\\/$","segments":[],"params":[],"component":"src/pages/index.astro","pathname":"/","prerender":false,"fallbackRoutes":[],"distURL":[],"origin":"project","_meta":{"trailingSlash":"ignore"}}}],"serverLike":true,"middlewareMode":"classic","base":"/","trailingSlash":"ignore","compressHTML":"jsx","componentMetadata":[["/home/runner/work/webr/webr/src/pages/new.astro",{"propagation":"none","containsHead":true}],["/home/runner/work/webr/webr/src/pages/sessions/[session]/tabs/[tab].astro",{"propagation":"none","containsHead":true}],["/home/runner/work/webr/webr/src/pages/settings.astro",{"propagation":"none","containsHead":true}],["/home/runner/work/webr/webr/src/pages/threads/[id].astro",{"propagation":"none","containsHead":true}]],"renderers":[],"clientDirectives":[["idle","(()=>{var l=(n,t)=>{let i=async()=>{await(await n())()},e=typeof t.value==\"object\"?t.value:void 0,s={timeout:e==null?void 0:e.timeout};\"requestIdleCallback\"in window?window.requestIdleCallback(i,s):setTimeout(i,s.timeout||200)};(self.Astro||(self.Astro={})).idle=l;window.dispatchEvent(new Event(\"astro:idle\"));})();"],["load","(()=>{var e=async t=>{await(await t())()};(self.Astro||(self.Astro={})).load=e;window.dispatchEvent(new Event(\"astro:load\"));})();"],["media","(()=>{var n=(a,t)=>{let i=async()=>{await(await a())()};if(t.value){let e=matchMedia(t.value);e.matches?i():e.addEventListener(\"change\",i,{once:!0})}};(self.Astro||(self.Astro={})).media=n;window.dispatchEvent(new Event(\"astro:media\"));})();"],["only","(()=>{var e=async t=>{await(await t())()};(self.Astro||(self.Astro={})).only=e;window.dispatchEvent(new Event(\"astro:only\"));})();"],["visible","(()=>{var a=(s,i,o)=>{let r=async()=>{await(await s())()},t=typeof i.value==\"object\"?i.value:void 0,c={rootMargin:t==null?void 0:t.rootMargin},n=new IntersectionObserver(e=>{for(let l of e)if(l.isIntersecting){n.disconnect(),r();break}},c);for(let e of o.children)n.observe(e)};(self.Astro||(self.Astro={})).visible=a;window.dispatchEvent(new Event(\"astro:visible\"));})();"]],"entryModules":{"\u0000noop-middleware":"virtual_astro_middleware.mjs","\u0000virtual:astro:server-island-manifest":"chunks/_virtual_astro_server-island-manifest_C1Q2srgE.mjs","\u0000virtual:astro:session-driver":"chunks/_virtual_astro_session-driver_Dp7RXjOf.mjs","\u0000virtual:astro:actions/noop-entrypoint":"chunks/noop-entrypoint_Z3zFhrGC.mjs","/home/runner/work/webr/webr/node_modules/.pnpm/@astrojs+react@7.0.0_@types+node@22.20.4_@types+react-dom@19.3.0_@types+react@19.3.0__@_02aa85dceaccf3d763325676dd8540a8/node_modules/@astrojs/react/dist/vnode-children.js":"chunks/vnode-children_BSi0H4PL.mjs","@astrojs/node/server.js":"entry.mjs","\u0000virtual:astro:page:src/pages/threads/[id]@_@astro":"chunks/_id__PkkT8hXq.mjs","\u0000virtual:astro:page:src/pages/sessions/[session]/tabs/[tab]@_@astro":"chunks/_tab__OowoBszk.mjs","\u0000virtual:astro:page:src/pages/index@_@astro":"chunks/index_Bvv1gpI0.mjs","\u0000virtual:astro:page:src/pages/new@_@astro":"chunks/new_DC57I9zU.mjs","\u0000virtual:astro:page:node_modules/.pnpm/astro@7.3.5_@types+node@22.20.4_tsx@4.23.15_yaml@2.9.1/node_modules/astro/dist/assets/endpoint/node@_@js":"chunks/node_C8FqXy_n.mjs","\u0000virtual:astro:page:src/pages/settings@_@astro":"chunks/settings_BIpDm2u1.mjs","/home/runner/work/webr/webr/node_modules/.pnpm/astro@7.3.5_@types+node@22.20.4_tsx@4.23.15_yaml@2.9.1/node_modules/astro/dist/assets/services/sharp.js":"chunks/sharp_8J8zk4Wd.mjs","/home/runner/work/webr/webr/src/components/App.tsx":"_astro/App.BtZuijDn.js","/home/runner/work/webr/webr/node_modules/.pnpm/astro@7.3.5_@types+node@22.20.4_tsx@4.23.15_yaml@2.9.1/node_modules/astro/components/ClientRouter.astro?astro&type=script&index=0&lang.ts":"_astro/ClientRouter.astro_astro_type_script_index_0_lang.CTgtIFyY.js","@astrojs/react/client.js":"_astro/client.BDkzSRUN.js","/home/runner/work/webr/webr/node_modules/.pnpm/@xterm+addon-fit@0.10.0_@xterm+xterm@5.5.0/node_modules/@xterm/addon-fit/lib/addon-fit.js":"_astro/addon-fit.jgKqzwky.js","/home/runner/work/webr/webr/node_modules/.pnpm/@xterm+addon-web-links@0.11.0_@xterm+xterm@5.5.0/node_modules/@xterm/addon-web-links/lib/addon-web-links.js":"_astro/addon-web-links.C9lyQRxI.js","/home/runner/work/webr/webr/node_modules/.pnpm/@xterm+addon-webgl@0.18.0_@xterm+xterm@5.5.0/node_modules/@xterm/addon-webgl/lib/addon-webgl.js":"_astro/addon-webgl.Bd5ox92E.js","/home/runner/work/webr/webr/node_modules/.pnpm/@xterm+xterm@5.5.0/node_modules/@xterm/xterm/lib/xterm.js":"_astro/xterm.B-l5AIf6.js","astro:scripts/before-hydration.js":""},"inlinedScripts":[],"assets":["/apple-touch-icon.png","/brand-mark.png","/favicon-16.png","/favicon-32.png","/favicon.ico","/icon-192.png","/icon-512.png","/icon-maskable-512.png","/manifest.webmanifest","/_astro/App.BtZuijDn.js","/_astro/ClientRouter.astro_astro_type_script_index_0_lang.CTgtIFyY.js","/_astro/addon-fit.jgKqzwky.js","/_astro/addon-web-links.C9lyQRxI.js","/_astro/addon-webgl.Bd5ox92E.js","/_astro/client.BDkzSRUN.js","/_astro/client.Bbg9OBwT.js","/_astro/client.CLSsMr2g.js","/_astro/xterm.B-l5AIf6.js","/_astro/App.CJakfIju.css","/_astro/jetbrains-mono.CFJaiWaY.woff2","/_astro/symbols-nerd.CEdkKAK8.woff2"],"buildFormat":"directory","checkOrigin":true,"actionBodySizeLimit":1048576,"serverIslandBodySizeLimit":1048576,"allowedDomains":[],"key":"bhQt7blSutZXqvha3yFzjOGVMCtQJ/c1nNTFxOla9cU=","sessionConfig":{"driver":"unstorage/drivers/fs-lite","options":{"base":"/home/runner/work/webr/webr/node_modules/.astro/sessions"}},"image":{},"devToolbar":{"enabled":false,"debugInfoOutput":""},"logLevel":"info","shouldInjectCspMetaTags":false});
|
|
2445
2445
|
var manifestRoutes = _manifest.routes;
|
|
2446
2446
|
var manifest = Object.assign(_manifest, {
|
|
2447
2447
|
renderers,
|
package/package.json
CHANGED
package/src/lib/machines.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export type MachineHarness = { id: string; name: string; models: string[]; customModels?: boolean; launchEnabled?: boolean; reason?: string };
|
|
2
|
+
export type MachineOs = 'macos' | 'linux' | 'windows';
|
|
2
3
|
export type Machine = {
|
|
3
4
|
id: string;
|
|
4
5
|
name: string;
|
|
@@ -7,6 +8,8 @@ export type Machine = {
|
|
|
7
8
|
projectPaths: Record<string, string>;
|
|
8
9
|
harnesses: MachineHarness[];
|
|
9
10
|
modelsPending?: boolean;
|
|
11
|
+
isGitRepo?: boolean;
|
|
12
|
+
os?: MachineOs;
|
|
10
13
|
error?: string;
|
|
11
14
|
writable?: boolean;
|
|
12
15
|
configVersion?: number;
|
|
@@ -14,7 +17,7 @@ export type Machine = {
|
|
|
14
17
|
|
|
15
18
|
export const machines: Machine[] = [
|
|
16
19
|
{
|
|
17
|
-
id: 'local', name: 'Local', connected: true, session: 'default',
|
|
20
|
+
id: 'local', name: 'Local', os: 'macos', connected: true, session: 'default',
|
|
18
21
|
projectPaths: { herdr: '~/src/herdr', maze: '~/src/maze/monorepo', spotifin: '~/src/spotifin' },
|
|
19
22
|
harnesses: [
|
|
20
23
|
{ id: 'claude', name: 'Claude Code', models: ['Default', 'sonnet', 'opus'] },
|
|
@@ -24,7 +27,7 @@ export const machines: Machine[] = [
|
|
|
24
27
|
],
|
|
25
28
|
},
|
|
26
29
|
{
|
|
27
|
-
id: 'demo-build', name: 'Build machine (demo)', connected: true, session: 'agents',
|
|
30
|
+
id: 'demo-build', name: 'Build machine (demo)', os: 'linux', connected: true, session: 'agents',
|
|
28
31
|
projectPaths: { herdr: '/srv/herdr', spotifin: '/srv/spotifin' },
|
|
29
32
|
harnesses: [
|
|
30
33
|
{ id: 'opencode', name: 'OpenCode', models: ['Default', 'demo/remote-model'] },
|
package/src/server/host.ts
CHANGED
|
@@ -14,11 +14,13 @@ import { isTrustedHost } from './auth/access';
|
|
|
14
14
|
import { registerDevHmrProxy } from './dev-hmr-proxy';
|
|
15
15
|
import { registerVoiceRoutes } from './voice/routes';
|
|
16
16
|
import type { Transcriber } from './voice/transcriber';
|
|
17
|
-
import { MAX_IMAGE_BYTES, UploadStore, uploadContentTypes } from './uploads';
|
|
17
|
+
import { MAX_IMAGE_BYTES, UploadStore, isFileUpload, uploadContentTypes } from './uploads';
|
|
18
18
|
|
|
19
19
|
const localOwner = 'local';
|
|
20
20
|
type SsrHandler = (request: FastifyRequest['raw'], response: import('node:http').ServerResponse, next: (error?: unknown) => void, locals: Record<string, unknown>) => void;
|
|
21
21
|
|
|
22
|
+
const MAX_PROJECT_ICON_BYTES = 512 * 1024;
|
|
23
|
+
|
|
22
24
|
export async function createHost(manager: RuntimeManager, origin: string, ssr?: SsrHandler, tls?: { key: Buffer; cert: Buffer }, uploads = new UploadStore(), authOptions: AuthOptions = {}, { devHmrPort, transcriber }: { devHmrPort?: number; transcriber?: Transcriber } = {}) {
|
|
23
25
|
const app = Fastify({ logger: false, bodyLimit: 16 * 1024, ...(tls ? { https: tls } : {}), requestTimeout: 10_000 });
|
|
24
26
|
const configuredOrigin = new URL(origin);
|
|
@@ -37,6 +39,8 @@ export async function createHost(manager: RuntimeManager, origin: string, ssr?:
|
|
|
37
39
|
if (error instanceof z.ZodError) return reply.code(400).send({ error: 'invalid_request' });
|
|
38
40
|
const status = (error as { statusCode?: number }).statusCode;
|
|
39
41
|
if (status === 413) return reply.code(413).send({ error: 'image_too_large' });
|
|
42
|
+
if (message === 'file_too_large') return reply.code(413).send({ error: message });
|
|
43
|
+
if (message === 'empty_file') return reply.code(400).send({ error: message });
|
|
40
44
|
if (status === 415 || message === 'unsupported_image') return reply.code(415).send({ error: 'unsupported_image' });
|
|
41
45
|
if (status && status >= 400 && status < 500) return reply.code(status).send({ error: 'invalid_request' });
|
|
42
46
|
if (message === 'thread_not_found') return reply.code(404).send({ error: message });
|
|
@@ -57,12 +61,13 @@ export async function createHost(manager: RuntimeManager, origin: string, ssr?:
|
|
|
57
61
|
app.get('/api/runtime', async () => manager.bootstrap());
|
|
58
62
|
app.addContentTypeParser(uploadContentTypes, { parseAs: 'buffer', bodyLimit: MAX_IMAGE_BYTES }, (_request, body, done) => done(null, body));
|
|
59
63
|
app.post('/api/uploads', { bodyLimit: MAX_IMAGE_BYTES }, async (request, reply) => {
|
|
60
|
-
const { machineId } = z.object({ machineId: opaqueId }).strict().parse(request.query);
|
|
64
|
+
const { machineId, name } = z.object({ machineId: opaqueId, name: z.string().max(255).optional() }).strict().parse(request.query);
|
|
61
65
|
const supervisor = manager.supervisors.get(machineId);
|
|
62
66
|
if (!supervisor?.connected) return reply.code(409).send({ error: 'machine_disconnected' });
|
|
63
67
|
if (!supervisor.target.acceptsLocalFiles) return reply.code(409).send({ error: 'uploads_unsupported_target' });
|
|
64
68
|
if (!Buffer.isBuffer(request.body)) return reply.code(415).send({ error: 'unsupported_image' });
|
|
65
|
-
|
|
69
|
+
const contentType = request.headers['content-type'];
|
|
70
|
+
return { path: isFileUpload(contentType) ? await uploads.saveFile(name, request.body) : await uploads.save(contentType, request.body) };
|
|
66
71
|
});
|
|
67
72
|
app.get('/api/directories', async (request, reply) => {
|
|
68
73
|
const { machineId, prefix } = z.object({ machineId: opaqueId, prefix: z.string().max(1000).refine((path) => !/[\x00-\x1f]/.test(path)) }).strict().parse(request.query);
|
|
@@ -87,10 +92,20 @@ export async function createHost(manager: RuntimeManager, origin: string, ssr?:
|
|
|
87
92
|
supervisor.invalidate();
|
|
88
93
|
return reply.code(result.state === 'unknown' ? 202 : 201).send(result);
|
|
89
94
|
});
|
|
95
|
+
const projectIconTarget = z.object({ machineId: opaqueId, logicalId: z.string().min(1).max(1000) }).strict();
|
|
96
|
+
app.put('/api/projects/icon', { bodyLimit: MAX_PROJECT_ICON_BYTES }, async (request, reply) => {
|
|
97
|
+
const { machineId, logicalId } = projectIconTarget.parse(request.query);
|
|
98
|
+
if (!Buffer.isBuffer(request.body)) return reply.code(415).send({ error: 'unsupported_image' });
|
|
99
|
+
manager.setProjectIcon(machineId, logicalId, request.headers['content-type'], request.body); return { saved: true };
|
|
100
|
+
});
|
|
101
|
+
app.delete('/api/projects/icon', async (request) => {
|
|
102
|
+
const { machineId, logicalId } = projectIconTarget.parse(request.query);
|
|
103
|
+
manager.resetProjectIcon(machineId, logicalId); return { reset: true };
|
|
104
|
+
});
|
|
90
105
|
app.get('/api/projects/:machineId/:projectId/icon', async (request, reply) => {
|
|
91
106
|
const { machineId, projectId } = z.object({ machineId: opaqueId, projectId: opaqueId }).parse(request.params);
|
|
92
107
|
const target = manager.supervisors.get(machineId)?.target;
|
|
93
|
-
const icon = await target?.icon?.(projectId);
|
|
108
|
+
const icon = manager.customProjectIcon(machineId, projectId) ?? await target?.icon?.(projectId);
|
|
94
109
|
if (!icon) return reply.code(404).send();
|
|
95
110
|
if (icon.contentType === 'image/svg+xml') reply.header('Content-Security-Policy', "sandbox; default-src 'none'; style-src 'unsafe-inline'");
|
|
96
111
|
return reply.type(icon.contentType).send(icon.bytes);
|
|
@@ -3,7 +3,9 @@ import type { TargetAdapter } from './target';
|
|
|
3
3
|
import type { Thread } from '../../lib/models';
|
|
4
4
|
import { canClosePane, insertAfter } from '../../lib/pane-strip';
|
|
5
5
|
import { TargetSupervisor } from './supervisor';
|
|
6
|
-
import { projectNameKey, reconcile } from './reconcile';
|
|
6
|
+
import { projectIconKey, projectNameKey, reconcile } from './reconcile';
|
|
7
|
+
import { imageExtension } from '../uploads';
|
|
8
|
+
import type { Icon } from '../transport/icons';
|
|
7
9
|
import type { MetadataDatabase } from '../storage/database';
|
|
8
10
|
import type { Bootstrap, Projection } from '../../shared/runtime';
|
|
9
11
|
import type { Machine } from '../../lib/machines';
|
|
@@ -39,9 +41,9 @@ export class RuntimeManager extends EventEmitter {
|
|
|
39
41
|
async close() { this.closed = true; for (const supervisor of this.supervisors.values()) supervisor.stop(); await this.journal.stop(); this.removeAllListeners(); }
|
|
40
42
|
bootstrap(): Bootstrap {
|
|
41
43
|
const projections = [...this.projections.values()];
|
|
42
|
-
return { projections, projects: projections.flatMap((projection) => projection.projects), threads: projections.flatMap((projection) => projection.threads), machines: [...this.supervisors.values()].map((supervisor) => ({ id: supervisor.target.id, name: supervisor.target.name, session: supervisor.target.session, connected: supervisor.connected, projectPaths: Object.fromEntries(supervisor.target.locations.map((location) => [location.projectId, location.path])), harnesses: [], error: supervisor.error, writable: supervisor.target.writable, configVersion: this.configVersions.get(supervisor.target.id) })) };
|
|
44
|
+
return { projections, projects: projections.flatMap((projection) => projection.projects), threads: projections.flatMap((projection) => projection.threads), machines: [...this.supervisors.values()].map((supervisor) => ({ id: supervisor.target.id, name: supervisor.target.name, session: supervisor.target.session, connected: supervisor.connected, projectPaths: Object.fromEntries(supervisor.target.locations.map((location) => [location.projectId, location.path])), harnesses: [], error: supervisor.error, writable: supervisor.target.writable, os: supervisor.target.os, configVersion: this.configVersions.get(supervisor.target.id) })) };
|
|
43
45
|
}
|
|
44
|
-
private machine(supervisor: TargetSupervisor): Machine { return { id: supervisor.target.id, name: supervisor.target.name, session: supervisor.target.session, connected: supervisor.connected, configVersion: supervisor.target.configVersion, writable: supervisor.target.writable, error: supervisor.error, projectPaths: Object.fromEntries(supervisor.target.locations.map((location) => [location.projectId, location.path])), harnesses: [] }; }
|
|
46
|
+
private machine(supervisor: TargetSupervisor): Machine { return { id: supervisor.target.id, name: supervisor.target.name, session: supervisor.target.session, connected: supervisor.connected, configVersion: supervisor.target.configVersion, writable: supervisor.target.writable, os: supervisor.target.os, error: supervisor.error, projectPaths: Object.fromEntries(supervisor.target.locations.map((location) => [location.projectId, location.path])), harnesses: [] }; }
|
|
45
47
|
async catalog(machineId: string, projectId?: string) {
|
|
46
48
|
const supervisor = this.supervisors.get(machineId);
|
|
47
49
|
if (!supervisor?.connected) throw new Error('machine_disconnected');
|
|
@@ -125,6 +127,26 @@ export class RuntimeManager extends EventEmitter {
|
|
|
125
127
|
this.database.setSetting(projectNameKey(machineId, logicalId), name);
|
|
126
128
|
this.publish(supervisor);
|
|
127
129
|
}
|
|
130
|
+
setProjectIcon(machineId: string, logicalId: string, contentType: string | undefined, bytes: Buffer) {
|
|
131
|
+
const supervisor = this.supervisors.get(machineId);
|
|
132
|
+
if (!supervisor) throw new Error('machine_disconnected');
|
|
133
|
+
imageExtension(contentType, bytes);
|
|
134
|
+
this.database.setSetting(projectIconKey(machineId, logicalId), `${contentType!.split(';')[0].trim()}|${bytes.toString('base64')}`);
|
|
135
|
+
this.publish(supervisor);
|
|
136
|
+
}
|
|
137
|
+
resetProjectIcon(machineId: string, logicalId: string) {
|
|
138
|
+
const supervisor = this.supervisors.get(machineId);
|
|
139
|
+
if (!supervisor) throw new Error('machine_disconnected');
|
|
140
|
+
this.database.deleteSetting(projectIconKey(machineId, logicalId));
|
|
141
|
+
this.publish(supervisor);
|
|
142
|
+
}
|
|
143
|
+
customProjectIcon(machineId: string, projectId: string): Icon | undefined {
|
|
144
|
+
const logicalId = this.projections.get(machineId)?.projects.find((project) => project.id === projectId)?.logicalId;
|
|
145
|
+
const stored = logicalId && this.database.getSetting(projectIconKey(machineId, logicalId));
|
|
146
|
+
if (!stored) return;
|
|
147
|
+
const [contentType, data] = stored.split('|');
|
|
148
|
+
return { contentType, bytes: Buffer.from(data, 'base64') };
|
|
149
|
+
}
|
|
128
150
|
async renameThread(machineId: string, threadId: string, title: string) {
|
|
129
151
|
const supervisor = this.supervisors.get(machineId);
|
|
130
152
|
const thread = this.projections.get(machineId)?.threads.find((thread) => thread.id === threadId);
|
|
@@ -5,6 +5,9 @@ import type { Project, Thread } from '../../lib/models';
|
|
|
5
5
|
import type { TargetAdapter } from './target';
|
|
6
6
|
import { orderPanes } from '../../lib/pane-strip';
|
|
7
7
|
|
|
8
|
+
export const projectIconKey = (machineId: string, logicalId: string) => `project-icon:${machineId}:${logicalId}`;
|
|
9
|
+
const iconRoute = (machineId: string, id: string, version = '') => `/api/projects/${encodeURIComponent(machineId)}/${encodeURIComponent(id)}/icon${version}`;
|
|
10
|
+
const customIconVersion = (value?: string) => value ? `?v=${createHash('sha1').update(value).digest('hex').slice(0, 8)}` : undefined;
|
|
8
11
|
export const projectNameKey = (machineId: string, logicalId: string) => `project-name:${machineId}:${logicalId}`;
|
|
9
12
|
export const projectIdentity = (machineId: string, repository: string) => createHash('sha256').update(machineId + '\0' + repository).digest('hex').slice(0, 32);
|
|
10
13
|
const sameBinding = (thread: Thread, target: TargetAdapter) => thread.session === target.session && thread.bindingFingerprint === target.fingerprint && thread.bindingConfigVersion === target.configVersion;
|
|
@@ -17,6 +20,12 @@ const threadTitle = (label: string | null | undefined, panes: NativeSnapshot['pa
|
|
|
17
20
|
return agent?.title?.trim() || agent?.terminal_title_stripped?.trim() || namedTab || fallback;
|
|
18
21
|
};
|
|
19
22
|
|
|
23
|
+
function projectIconUrl(database: MetadataDatabase, target: TargetAdapter, id: string, logicalId: string, located: boolean) {
|
|
24
|
+
const custom = customIconVersion(database.getSetting(projectIconKey(target.id, logicalId)));
|
|
25
|
+
if (custom) return iconRoute(target.id, id, custom);
|
|
26
|
+
return target.icon && located ? iconRoute(target.id, id) : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
export function reconcile(database: MetadataDatabase, target: TargetAdapter, snapshot: NativeSnapshot): { threads: Thread[]; projects: Project[] } {
|
|
21
30
|
const saved = database.threadRows(target.id);
|
|
22
31
|
const projects: Project[] = [];
|
|
@@ -26,7 +35,7 @@ export function reconcile(database: MetadataDatabase, target: TargetAdapter, sna
|
|
|
26
35
|
const location = target.locations.find((location) => location.workspaceId === workspace.workspace_id || location.path === cwd);
|
|
27
36
|
const id = location?.projectId ?? projectIdentity(target.id, workspace.worktree?.repo_key ?? (cwd || workspace.workspace_id));
|
|
28
37
|
const logicalId = location?.logicalId ?? workspace.worktree?.repo_key ?? id;
|
|
29
|
-
if (!projects.some((project) => project.id === id)) projects.push({ id, machineId: target.id, localId: location?.localId, logicalId, name: database.getSetting(projectNameKey(target.id, logicalId)) ?? workspace.worktree?.repo_name ?? workspace.label, path: location?.path ?? cwd, color: '#9b83df', initial: (workspace.label[0] ?? 'P').toUpperCase(), iconUrl: target
|
|
38
|
+
if (!projects.some((project) => project.id === id)) projects.push({ id, machineId: target.id, localId: location?.localId, logicalId, name: database.getSetting(projectNameKey(target.id, logicalId)) ?? workspace.worktree?.repo_name ?? workspace.label, path: location?.path ?? cwd, color: '#9b83df', initial: (workspace.label[0] ?? 'P').toUpperCase(), iconUrl: projectIconUrl(database, target, id, logicalId, !!location) });
|
|
30
39
|
for (const tab of snapshot.tabs.filter((tab) => tab.workspace_id === workspace.workspace_id)) tabProjects.set(tab.tab_id, id);
|
|
31
40
|
}
|
|
32
41
|
const launching = database.operations().some((operation) => operation.input.machineId === target.id && ['pending', 'running'].includes(operation.state));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Machine } from '../../lib/machines';
|
|
1
|
+
import type { Machine, MachineOs } from '../../lib/machines';
|
|
2
2
|
import type { NativeSnapshot } from '../protocol/native';
|
|
3
3
|
import type { TerminalFrame, TerminalStream } from '../terminal/cli';
|
|
4
4
|
import type { LaunchInput } from '../../shared/runtime';
|
|
@@ -14,6 +14,7 @@ export type TargetAdapter = {
|
|
|
14
14
|
enabled?: boolean;
|
|
15
15
|
locations: LaunchLocation[];
|
|
16
16
|
writable: boolean;
|
|
17
|
+
os?: MachineOs;
|
|
17
18
|
acceptsLocalFiles?: boolean;
|
|
18
19
|
subscribe(onEvent: () => void, onClose: (reason: string) => void, paneIds?: string[]): Promise<() => void>;
|
|
19
20
|
snapshot(): Promise<NativeSnapshot>;
|
|
@@ -25,6 +25,7 @@ export class MetadataDatabase {
|
|
|
25
25
|
}
|
|
26
26
|
close() { this.sqlite.close(); }
|
|
27
27
|
getSetting(key: string) { return this.db.select().from(schema.settings).where(eq(schema.settings.key, key)).get()?.value; }
|
|
28
|
+
deleteSetting(key: string) { this.db.delete(schema.settings).where(eq(schema.settings.key, key)).run(); }
|
|
28
29
|
setSetting(key: string, value: string) { this.db.insert(schema.settings).values({ key, value }).onConflictDoUpdate({ target: schema.settings.key, set: { value } }).run(); }
|
|
29
30
|
threadRows(machineId?: string, session?: string) {
|
|
30
31
|
return this.db.select().from(schema.threads).where(machineId ? and(eq(schema.threads.machineId, machineId), session ? eq(schema.threads.session, session) : undefined) : undefined).all();
|
|
@@ -7,7 +7,7 @@ import { openCliStream } from '../terminal/cli';
|
|
|
7
7
|
import { boundedProcess } from './process';
|
|
8
8
|
import { SshForward, sshOptions, remoteCommand, quoteShell } from './ssh';
|
|
9
9
|
import type { LaunchInput } from '../../shared/runtime';
|
|
10
|
-
import type { Machine } from '../../lib/machines';
|
|
10
|
+
import type { Machine, MachineOs } from '../../lib/machines';
|
|
11
11
|
import { harnessName } from '../../lib/models';
|
|
12
12
|
import { acceptsModelFlag, herdrAgentKinds, isHerdrAgentKind } from '../../shared/agent-kinds';
|
|
13
13
|
import { modelListings, parseModelListing } from './model-discovery';
|
|
@@ -23,16 +23,19 @@ import { expandHome, listLocalDirectories, suggestDirectories } from '../directo
|
|
|
23
23
|
const MODEL_LISTING_TIMEOUT_MS = 15_000;
|
|
24
24
|
const MODEL_CACHE_TTL_MS = 10 * 60_000;
|
|
25
25
|
const MODEL_LISTING_LIMIT = 4 * 1024 * 1024;
|
|
26
|
+
const osFromPlatform = (platform: string): MachineOs | undefined => platform === 'darwin' || platform === 'Darwin' ? 'macos' : platform === 'win32' || /^(MINGW|MSYS|CYGWIN)/i.test(platform) ? 'windows' : platform.toLowerCase() === 'linux' ? 'linux' : undefined;
|
|
26
27
|
type Dependencies = { process: typeof boundedProcess; cli: typeof openCliStream };
|
|
27
28
|
export class HerdrTarget implements TargetAdapter {
|
|
28
29
|
readonly id; readonly name; readonly session; readonly locations: LaunchLocation[];
|
|
29
30
|
readonly fingerprint; configVersion = 1;
|
|
30
31
|
get enabled() { return this.profile.enabled; }
|
|
31
32
|
get writable() { return this.compatible; }
|
|
33
|
+
get os() { return this.profile.transport === 'local' ? osFromPlatform(process.platform) : this.remoteOs; }
|
|
32
34
|
get acceptsLocalFiles() { return this.profile.transport === 'local'; }
|
|
33
35
|
private api?: SocketApi;
|
|
34
36
|
private forwarding?: SshForward;
|
|
35
37
|
private compatible = false;
|
|
38
|
+
private remoteOs?: MachineOs;
|
|
36
39
|
private connecting?: Promise<SocketApi>;
|
|
37
40
|
private streams = new Set<ReturnType<typeof openCliStream>>();
|
|
38
41
|
private icons = new Map<string, { expires: number; value: Promise<Icon | undefined> }>();
|
|
@@ -67,6 +70,7 @@ export class HerdrTarget implements TargetAdapter {
|
|
|
67
70
|
const schema = JSON.parse(await this.dependencies.process(schemaCommand.command, schemaCommand.args, schemaCommand.env, 5000, 2 * 1024 * 1024));
|
|
68
71
|
if (validateInstalledSchema(schema).length) { this.compatible = false; throw new Error('unsupported_herdr_schema'); }
|
|
69
72
|
this.compatible = true;
|
|
73
|
+
if (this.profile.transport === 'ssh') void this.detectRemoteOs();
|
|
70
74
|
return this.api;
|
|
71
75
|
})();
|
|
72
76
|
try { return await this.connecting; }
|
|
@@ -89,7 +93,7 @@ export class HerdrTarget implements TargetAdapter {
|
|
|
89
93
|
if (tab.workspace_id !== workspace.workspace_id || pane.workspace_id !== workspace.workspace_id || pane.tab_id !== tab.tab_id) throw new Error('workspace_identity_mismatch');
|
|
90
94
|
return { workspaceId: workspace.workspace_id, tabId: tab.tab_id, terminalId: pane.terminal_id };
|
|
91
95
|
}
|
|
92
|
-
async catalog(
|
|
96
|
+
async catalog(projectId?: string): Promise<Machine> {
|
|
93
97
|
const api = await this.connect();
|
|
94
98
|
await api.request('ping');
|
|
95
99
|
const run = (command: string, args: string[]) => this.runOnTarget(command, args);
|
|
@@ -97,7 +101,16 @@ export class HerdrTarget implements TargetAdapter {
|
|
|
97
101
|
const present = (await Promise.all(herdrAgentKinds.map(async (kind) => (await isPresent(kind)) ? kind : undefined))).filter((kind): kind is string => !!kind);
|
|
98
102
|
present.forEach((kind) => this.discoverModels(kind));
|
|
99
103
|
const modelsPending = present.some((kind) => this.pendingModels.has(kind));
|
|
100
|
-
|
|
104
|
+
const isGitRepo = await this.isGitRepo(projectId);
|
|
105
|
+
return { id: this.id, name: this.name, session: this.session, connected: true, writable: this.writable, configVersion: this.configVersion, modelsPending, isGitRepo, projectPaths: Object.fromEntries(this.locations.map((location) => [location.projectId, location.path])), harnesses: present.map((id) => ({ id, name: harnessName(id), models: ['Default', ...(this.discoveredModels.get(id)?.models ?? [])], customModels: acceptsModelFlag(id), launchEnabled: true })) };
|
|
106
|
+
}
|
|
107
|
+
private async detectRemoteOs() {
|
|
108
|
+
this.remoteOs = await this.runOnTarget('uname', ['-s']).then((output) => osFromPlatform(output.trim()), () => undefined);
|
|
109
|
+
}
|
|
110
|
+
private async isGitRepo(projectId?: string) {
|
|
111
|
+
const location = this.locations.find((location) => location.projectId === projectId);
|
|
112
|
+
if (!location) return true;
|
|
113
|
+
return this.runOnTarget('git', ['-C', await this.expandHome(location.path), 'rev-parse', '--is-inside-work-tree']).then(() => true, () => false);
|
|
101
114
|
}
|
|
102
115
|
private discoveredModels = new Map<string, { models: string[]; expires: number }>();
|
|
103
116
|
private pendingModels = new Set<string>();
|
|
@@ -2,7 +2,8 @@ import { readdir, realpath, stat, readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { basename, dirname, extname, isAbsolute, join, relative, sep } from 'node:path';
|
|
3
3
|
|
|
4
4
|
const contentTypes: Record<string, string> = { '.ico': 'image/x-icon', '.png': 'image/png', '.svg': 'image/svg+xml', '.webp': 'image/webp' };
|
|
5
|
-
const
|
|
5
|
+
const maxBytes = 2 * 1024 * 1024;
|
|
6
|
+
const directories = ['build/Icon.icon/Assets', '', 'public', 'static', 'assets', 'web', 'web/public', 'app/public', 'src/assets', 'src/app', 'frontend/public'];
|
|
6
7
|
export type Icon = { bytes: Buffer; contentType: string };
|
|
7
8
|
|
|
8
9
|
function withinRoot(root: string, path: string) {
|
|
@@ -37,9 +38,9 @@ async function iconInDirectory(root: string, directory: string): Promise<Icon |
|
|
|
37
38
|
const file = await realpath(join(actual, entry.name));
|
|
38
39
|
if (!withinRoot(root, file)) continue;
|
|
39
40
|
const info = await stat(file);
|
|
40
|
-
if (!info.isFile() || info.size <= 0 || info.size >
|
|
41
|
+
if (!info.isFile() || info.size <= 0 || info.size > maxBytes) continue;
|
|
41
42
|
const bytes = await readFile(file);
|
|
42
|
-
if (bytes.length <=
|
|
43
|
+
if (bytes.length <= maxBytes) return { bytes, contentType: contentTypes[extname(entry.name).toLowerCase()] };
|
|
43
44
|
} catch {}
|
|
44
45
|
}
|
|
45
46
|
}
|
|
@@ -55,10 +56,17 @@ async function appDirectories(root: string) {
|
|
|
55
56
|
.slice(0, 32).flatMap((entry) => ['public', 'static', 'assets', 'src/assets', 'src/app'].map((part) => join(apps, entry.name, part)));
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
async function sourceDirectories(root: string) {
|
|
60
|
+
try {
|
|
61
|
+
return (await readdir(join(root, 'src'), { withFileTypes: true })).filter((entry) => entry.isDirectory())
|
|
62
|
+
.map((entry) => entry.name).sort().slice(0, 8).map((name) => join(root, 'src', name, 'public'));
|
|
63
|
+
} catch { return []; }
|
|
64
|
+
}
|
|
65
|
+
|
|
58
66
|
export async function readApprovedIcon(path: string): Promise<Icon | undefined> {
|
|
59
67
|
let root: string;
|
|
60
68
|
try { root = await realpath(path); } catch { return; }
|
|
61
|
-
for (const directory of [...directories.map((part) => join(root, part)), ...await appDirectories(root)]) {
|
|
69
|
+
for (const directory of [...directories.map((part) => join(root, part)), ...await sourceDirectories(root), ...await appDirectories(root)]) {
|
|
62
70
|
const icon = await iconInDirectory(root, directory);
|
|
63
71
|
if (icon) return icon;
|
|
64
72
|
}
|
package/src/server/uploads.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
|
|
5
5
|
export const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
6
|
+
export const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
7
|
+
export const FILE_CONTENT_TYPE = 'application/octet-stream';
|
|
6
8
|
const UPLOAD_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
|
|
7
9
|
const startsWith = (bytes: Buffer, signature: number[], offset = 0) => signature.every((byte, index) => bytes[offset + index] === byte);
|
|
8
10
|
const ascii = (text: string) => [...text].map((character) => character.charCodeAt(0));
|
|
@@ -12,7 +14,9 @@ const imageTypes: Record<string, { extension: string; matches: (bytes: Buffer) =
|
|
|
12
14
|
'image/gif': { extension: 'gif', matches: (bytes) => startsWith(bytes, ascii('GIF87a')) || startsWith(bytes, ascii('GIF89a')) },
|
|
13
15
|
'image/webp': { extension: 'webp', matches: (bytes) => startsWith(bytes, ascii('RIFF')) && startsWith(bytes, ascii('WEBP'), 8) },
|
|
14
16
|
};
|
|
15
|
-
export const uploadContentTypes = Object.keys(imageTypes);
|
|
17
|
+
export const uploadContentTypes = [...Object.keys(imageTypes), FILE_CONTENT_TYPE];
|
|
18
|
+
export const isFileUpload = (contentType: string | undefined) => contentType?.split(';')[0].trim().toLowerCase() === FILE_CONTENT_TYPE;
|
|
19
|
+
const extensionOf = (name: string | undefined) => /\.[A-Za-z0-9]{1,10}$/.exec(name ?? '')?.[0].toLowerCase() ?? '';
|
|
16
20
|
|
|
17
21
|
export function imageExtension(contentType: string | undefined, bytes: Buffer) {
|
|
18
22
|
const type = imageTypes[contentType?.split(';')[0].trim().toLowerCase() ?? ''];
|
|
@@ -23,16 +27,23 @@ export function imageExtension(contentType: string | undefined, bytes: Buffer) {
|
|
|
23
27
|
export class UploadStore {
|
|
24
28
|
constructor(readonly directory = resolve(process.env.WEBR_UPLOADS ?? '.data/uploads'), private lifetimeMs = UPLOAD_LIFETIME_MS) {}
|
|
25
29
|
async save(contentType: string | undefined, bytes: Buffer) {
|
|
26
|
-
|
|
30
|
+
return this.write(`image-${randomUUID()}.${imageExtension(contentType, bytes)}`, bytes);
|
|
31
|
+
}
|
|
32
|
+
async saveFile(name: string | undefined, bytes: Buffer) {
|
|
33
|
+
if (!bytes.length) throw new Error('empty_file');
|
|
34
|
+
if (bytes.length > MAX_FILE_BYTES) throw new Error('file_too_large');
|
|
35
|
+
return this.write(`file-${randomUUID()}${extensionOf(name)}`, bytes);
|
|
36
|
+
}
|
|
37
|
+
private async write(fileName: string, bytes: Buffer) {
|
|
27
38
|
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
28
|
-
const path = join(this.directory,
|
|
39
|
+
const path = join(this.directory, fileName);
|
|
29
40
|
await writeFile(path, bytes, { mode: 0o600, flag: 'wx' });
|
|
30
41
|
void this.removeExpired().catch(() => {});
|
|
31
42
|
return path;
|
|
32
43
|
}
|
|
33
44
|
async removeExpired(now = Date.now()) {
|
|
34
45
|
for (const name of await readdir(this.directory).catch(() => [] as string[])) {
|
|
35
|
-
if (!/^image-[0-9a-f-]{36}\.(png|jpg|gif|webp)$/.test(name)) continue;
|
|
46
|
+
if (!/^(image-[0-9a-f-]{36}\.(png|jpg|gif|webp)|file-[0-9a-f-]{36}(\.[a-z0-9]{1,10})?)$/.test(name)) continue;
|
|
36
47
|
const path = join(this.directory, name);
|
|
37
48
|
if (now - (await stat(path)).mtimeMs > this.lifetimeMs) await rm(path, { force: true });
|
|
38
49
|
}
|