@pablopunk/webr 0.3.8 → 0.3.10

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.
@@ -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, uploadContentTypes, uploadFileName } 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,9 @@ 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 });
44
+ if (message === 'upload_failed') return reply.code(502).send({ error: message });
40
45
  if (status === 415 || message === 'unsupported_image') return reply.code(415).send({ error: 'unsupported_image' });
41
46
  if (status && status >= 400 && status < 500) return reply.code(status).send({ error: 'invalid_request' });
42
47
  if (message === 'thread_not_found') return reply.code(404).send({ error: message });
@@ -57,12 +62,13 @@ export async function createHost(manager: RuntimeManager, origin: string, ssr?:
57
62
  app.get('/api/runtime', async () => manager.bootstrap());
58
63
  app.addContentTypeParser(uploadContentTypes, { parseAs: 'buffer', bodyLimit: MAX_IMAGE_BYTES }, (_request, body, done) => done(null, body));
59
64
  app.post('/api/uploads', { bodyLimit: MAX_IMAGE_BYTES }, async (request, reply) => {
60
- const { machineId } = z.object({ machineId: opaqueId }).strict().parse(request.query);
65
+ const { machineId, name } = z.object({ machineId: opaqueId, name: z.string().max(255).optional() }).strict().parse(request.query);
61
66
  const supervisor = manager.supervisors.get(machineId);
62
67
  if (!supervisor?.connected) return reply.code(409).send({ error: 'machine_disconnected' });
63
- if (!supervisor.target.acceptsLocalFiles) return reply.code(409).send({ error: 'uploads_unsupported_target' });
68
+ if (!supervisor.target.acceptsUploads) return reply.code(409).send({ error: 'uploads_unsupported_target' });
64
69
  if (!Buffer.isBuffer(request.body)) return reply.code(415).send({ error: 'unsupported_image' });
65
- return { path: await uploads.save(request.headers['content-type'], request.body) };
70
+ const fileName = uploadFileName(request.headers['content-type'], name, request.body);
71
+ return { path: supervisor.target.storeUpload ? await supervisor.target.storeUpload(fileName, request.body) : await uploads.saveAs(fileName, request.body) };
66
72
  });
67
73
  app.get('/api/directories', async (request, reply) => {
68
74
  const { machineId, prefix } = z.object({ machineId: opaqueId, prefix: z.string().max(1000).refine((path) => !/[\x00-\x1f]/.test(path)) }).strict().parse(request.query);
@@ -87,10 +93,20 @@ export async function createHost(manager: RuntimeManager, origin: string, ssr?:
87
93
  supervisor.invalidate();
88
94
  return reply.code(result.state === 'unknown' ? 202 : 201).send(result);
89
95
  });
96
+ const projectIconTarget = z.object({ machineId: opaqueId, logicalId: z.string().min(1).max(1000) }).strict();
97
+ app.put('/api/projects/icon', { bodyLimit: MAX_PROJECT_ICON_BYTES }, async (request, reply) => {
98
+ const { machineId, logicalId } = projectIconTarget.parse(request.query);
99
+ if (!Buffer.isBuffer(request.body)) return reply.code(415).send({ error: 'unsupported_image' });
100
+ manager.setProjectIcon(machineId, logicalId, request.headers['content-type'], request.body); return { saved: true };
101
+ });
102
+ app.delete('/api/projects/icon', async (request) => {
103
+ const { machineId, logicalId } = projectIconTarget.parse(request.query);
104
+ manager.resetProjectIcon(machineId, logicalId); return { reset: true };
105
+ });
90
106
  app.get('/api/projects/:machineId/:projectId/icon', async (request, reply) => {
91
107
  const { machineId, projectId } = z.object({ machineId: opaqueId, projectId: opaqueId }).parse(request.params);
92
108
  const target = manager.supervisors.get(machineId)?.target;
93
- const icon = await target?.icon?.(projectId);
109
+ const icon = manager.customProjectIcon(machineId, projectId) ?? await target?.icon?.(projectId);
94
110
  if (!icon) return reply.code(404).send();
95
111
  if (icon.contentType === 'image/svg+xml') reply.header('Content-Security-Policy', "sandbox; default-src 'none'; style-src 'unsafe-inline'");
96
112
  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.icon && location ? `/api/projects/${encodeURIComponent(target.id)}/${encodeURIComponent(id)}/icon` : undefined });
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,7 +14,9 @@ export type TargetAdapter = {
14
14
  enabled?: boolean;
15
15
  locations: LaunchLocation[];
16
16
  writable: boolean;
17
- acceptsLocalFiles?: boolean;
17
+ os?: MachineOs;
18
+ acceptsUploads?: boolean;
19
+ storeUpload?(fileName: string, bytes: Buffer): Promise<string>;
18
20
  subscribe(onEvent: () => void, onClose: (reason: string) => void, paneIds?: string[]): Promise<() => void>;
19
21
  snapshot(): Promise<NativeSnapshot>;
20
22
  catalog(projectId?: string): Promise<Machine>;
@@ -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();
@@ -5,9 +5,10 @@ import { SocketApi } from '../protocol/socket';
5
5
  import { nativeSnapshot, nativeWorkspace, nativeTab, nativePane } from '../protocol/native';
6
6
  import { openCliStream } from '../terminal/cli';
7
7
  import { boundedProcess } from './process';
8
+ import { uploadOverSsh } from './remote-upload';
8
9
  import { SshForward, sshOptions, remoteCommand, quoteShell } from './ssh';
9
10
  import type { LaunchInput } from '../../shared/runtime';
10
- import type { Machine } from '../../lib/machines';
11
+ import type { Machine, MachineOs } from '../../lib/machines';
11
12
  import { harnessName } from '../../lib/models';
12
13
  import { acceptsModelFlag, herdrAgentKinds, isHerdrAgentKind } from '../../shared/agent-kinds';
13
14
  import { modelListings, parseModelListing } from './model-discovery';
@@ -23,20 +24,25 @@ import { expandHome, listLocalDirectories, suggestDirectories } from '../directo
23
24
  const MODEL_LISTING_TIMEOUT_MS = 15_000;
24
25
  const MODEL_CACHE_TTL_MS = 10 * 60_000;
25
26
  const MODEL_LISTING_LIMIT = 4 * 1024 * 1024;
27
+ 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
28
  type Dependencies = { process: typeof boundedProcess; cli: typeof openCliStream };
27
29
  export class HerdrTarget implements TargetAdapter {
28
30
  readonly id; readonly name; readonly session; readonly locations: LaunchLocation[];
29
31
  readonly fingerprint; configVersion = 1;
30
32
  get enabled() { return this.profile.enabled; }
31
33
  get writable() { return this.compatible; }
32
- get acceptsLocalFiles() { return this.profile.transport === 'local'; }
34
+ get os() { return this.profile.transport === 'local' ? osFromPlatform(process.platform) : this.remoteOs; }
35
+ get acceptsUploads() { return this.profile.transport === 'local' || this.remoteOs !== 'windows'; }
36
+ storeUpload?: (fileName: string, bytes: Buffer) => Promise<string>;
33
37
  private api?: SocketApi;
34
38
  private forwarding?: SshForward;
35
39
  private compatible = false;
40
+ private remoteOs?: MachineOs;
36
41
  private connecting?: Promise<SocketApi>;
37
42
  private streams = new Set<ReturnType<typeof openCliStream>>();
38
43
  private icons = new Map<string, { expires: number; value: Promise<Icon | undefined> }>();
39
44
  constructor(private profile: TargetProfile, private dependencies: Dependencies = { process: boundedProcess, cli: openCliStream }) {
45
+ if (profile.transport === 'ssh') this.storeUpload = (fileName, bytes) => uploadOverSsh(profile.host!, fileName, bytes);
40
46
  this.id = profile.id; this.name = profile.name; this.session = profile.session;
41
47
  this.fingerprint = targetFingerprint(profile);
42
48
  this.locations = profile.locations.map((location) => ({ ...location, localId: location.projectId, logicalId: location.logicalId ?? location.projectId, projectId: scopedProjectId(profile.id, location.projectId) }));
@@ -67,6 +73,7 @@ export class HerdrTarget implements TargetAdapter {
67
73
  const schema = JSON.parse(await this.dependencies.process(schemaCommand.command, schemaCommand.args, schemaCommand.env, 5000, 2 * 1024 * 1024));
68
74
  if (validateInstalledSchema(schema).length) { this.compatible = false; throw new Error('unsupported_herdr_schema'); }
69
75
  this.compatible = true;
76
+ if (this.profile.transport === 'ssh') void this.detectRemoteOs();
70
77
  return this.api;
71
78
  })();
72
79
  try { return await this.connecting; }
@@ -89,7 +96,7 @@ export class HerdrTarget implements TargetAdapter {
89
96
  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
97
  return { workspaceId: workspace.workspace_id, tabId: tab.tab_id, terminalId: pane.terminal_id };
91
98
  }
92
- async catalog(_projectId?: string): Promise<Machine> {
99
+ async catalog(projectId?: string): Promise<Machine> {
93
100
  const api = await this.connect();
94
101
  await api.request('ping');
95
102
  const run = (command: string, args: string[]) => this.runOnTarget(command, args);
@@ -97,7 +104,16 @@ export class HerdrTarget implements TargetAdapter {
97
104
  const present = (await Promise.all(herdrAgentKinds.map(async (kind) => (await isPresent(kind)) ? kind : undefined))).filter((kind): kind is string => !!kind);
98
105
  present.forEach((kind) => this.discoverModels(kind));
99
106
  const modelsPending = present.some((kind) => this.pendingModels.has(kind));
100
- return { id: this.id, name: this.name, session: this.session, connected: true, writable: this.writable, configVersion: this.configVersion, modelsPending, 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 })) };
107
+ const isGitRepo = await this.isGitRepo(projectId);
108
+ 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 })) };
109
+ }
110
+ private async detectRemoteOs() {
111
+ this.remoteOs = await this.runOnTarget('uname', ['-s']).then((output) => osFromPlatform(output.trim()), () => undefined);
112
+ }
113
+ private async isGitRepo(projectId?: string) {
114
+ const location = this.locations.find((location) => location.projectId === projectId);
115
+ if (!location) return true;
116
+ return this.runOnTarget('git', ['-C', await this.expandHome(location.path), 'rev-parse', '--is-inside-work-tree']).then(() => true, () => false);
101
117
  }
102
118
  private discoveredModels = new Map<string, { models: string[]; expires: number }>();
103
119
  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 directories = ['', 'public', 'static', 'assets', 'web', 'web/public', 'app/public', 'src/assets', 'src/app', 'frontend/public'];
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 > 1024 * 1024) continue;
41
+ if (!info.isFile() || info.size <= 0 || info.size > maxBytes) continue;
41
42
  const bytes = await readFile(file);
42
- if (bytes.length <= 1024 * 1024) return { bytes, contentType: contentTypes[extname(entry.name).toLowerCase()] };
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
  }
@@ -0,0 +1,40 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { quoteShell, sshOptions } from './ssh';
3
+
4
+ const UPLOAD_TIMEOUT_MS = 60_000;
5
+ const EXPIRY_DAYS = 7;
6
+ const OUTPUT_LIMIT = 4096;
7
+ const hostPattern = /^[A-Za-z0-9_][A-Za-z0-9_.@-]{0,200}$/;
8
+ const fileNamePattern = /^(image|file)-[0-9a-f-]{36}(\.[a-z0-9]{1,10})?$/;
9
+ const absolutePath = /^\/[^\0\r\n]+$/;
10
+
11
+ export const remoteUploadScript = (fileName: string) => {
12
+ if (!fileNamePattern.test(fileName)) throw new Error('invalid_upload_name');
13
+ return [
14
+ 'set -e', 'umask 077',
15
+ 'dir="${XDG_CACHE_HOME:-$HOME/.cache}/webr-uploads"', 'mkdir -p "$dir"',
16
+ `target="$dir/${fileName}"`,
17
+ 'cat > "$target.part"', 'mv "$target.part" "$target"',
18
+ `find "$dir" -type f \\( -name 'image-*' -o -name 'file-*' \\) -mtime +${EXPIRY_DAYS} -exec rm -f {} + >/dev/null 2>&1 || true`,
19
+ 'printf \'%s\\n\' "$target"',
20
+ ].join('\n');
21
+ };
22
+
23
+ export function uploadOverSsh(host: string, fileName: string, bytes: Buffer, spawnProcess: typeof spawn = spawn, timeoutMs = UPLOAD_TIMEOUT_MS): Promise<string> {
24
+ if (!hostPattern.test(host)) throw new Error('invalid_ssh_target');
25
+ const command = 'sh -c ' + quoteShell(remoteUploadScript(fileName));
26
+ return new Promise((resolve, reject) => {
27
+ const child = spawnProcess('ssh', [...sshOptions, host, command], { stdio: ['pipe', 'pipe', 'ignore'] });
28
+ let output = ''; let settled = false;
29
+ const settle = (error?: Error, path?: string) => {
30
+ if (settled) return; settled = true; clearTimeout(timer);
31
+ if (error) { child.kill('SIGTERM'); reject(error); } else resolve(path!);
32
+ };
33
+ const timer = setTimeout(() => settle(new Error('upload_failed')), timeoutMs);
34
+ child.stdout!.on('data', (chunk: Buffer) => { output += chunk; if (output.length > OUTPUT_LIMIT) settle(new Error('upload_failed')); });
35
+ child.stdin!.on('error', () => {});
36
+ child.on('error', () => settle(new Error('upload_failed')));
37
+ child.on('close', (code) => { const path = output.trim().split('\n').at(-1) ?? ''; if (code === 0 && absolutePath.test(path)) settle(undefined, path); else settle(new Error('upload_failed')); });
38
+ child.stdin!.end(bytes);
39
+ });
40
+ }
@@ -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() ?? ''];
@@ -20,19 +24,26 @@ export function imageExtension(contentType: string | undefined, bytes: Buffer) {
20
24
  return type.extension;
21
25
  }
22
26
 
27
+ export function uploadFileName(contentType: string | undefined, name: string | undefined, bytes: Buffer) {
28
+ if (!isFileUpload(contentType)) return `image-${randomUUID()}.${imageExtension(contentType, bytes)}`;
29
+ if (!bytes.length) throw new Error('empty_file');
30
+ if (bytes.length > MAX_FILE_BYTES) throw new Error('file_too_large');
31
+ return `file-${randomUUID()}${extensionOf(name)}`;
32
+ }
33
+
23
34
  export class UploadStore {
24
35
  constructor(readonly directory = resolve(process.env.WEBR_UPLOADS ?? '.data/uploads'), private lifetimeMs = UPLOAD_LIFETIME_MS) {}
25
- async save(contentType: string | undefined, bytes: Buffer) {
26
- const extension = imageExtension(contentType, bytes);
36
+ save(contentType: string | undefined, bytes: Buffer, name?: string) { return this.saveAs(uploadFileName(contentType, name, bytes), bytes); }
37
+ async saveAs(fileName: string, bytes: Buffer) {
27
38
  await mkdir(this.directory, { recursive: true, mode: 0o700 });
28
- const path = join(this.directory, `image-${randomUUID()}.${extension}`);
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
  }