yamlite 0.40.0 → 0.41.1

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/dist/cli.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { B as check, I as createHandler, R as isLoopback, a as query, c as exportSite, i as formatTable, n as formatCsv, o as generateConfig, r as formatJson, s as init, t as open, z as loopbackHosts } from "./src-NOJw_gTE.mjs";
3
- import { n as guide, r as envKeys, t as createWorkspace } from "./workspace-Cp5-yHYp.mjs";
2
+ import { B as check, I as createHandler, R as isLoopback, a as query, c as exportSite, i as formatTable, n as formatCsv, o as generateConfig, r as formatJson, s as init, t as open, z as loopbackHosts } from "./src-ChJZPkg9.mjs";
3
+ import { n as guide, r as envKeys, t as createWorkspace } from "./workspace-D7xHlBPc.mjs";
4
4
  import { createRequire } from "node:module";
5
5
  import { relative, resolve } from "node:path";
6
6
  import { homedir } from "node:os";
package/dist/core.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { A as parseConfig, B as HistoryEntry, C as Decision, D as OpenOptions, E as DeclaredTable, F as ColumnType, G as Rec, H as IndexSpec, I as ConflictInfo, J as ViewRecord, K as SchemaChange, L as DbRow, M as Capture, N as Change, O as TableInput, P as ChangeOp, R as DbValue, S as BaseHashes, T as Config, U as PageAccess, V as HistoryPage, W as PageSpec, _ as check, a as formatCsv, b as Watcher, c as query, d as init, f as ExportOptions, g as CheckTable, h as CheckResult, i as QueryResult, j as readsFile, k as TableLocation, l as InitOptions, m as exportSite, n as open, o as formatJson, p as ExportResult, q as TableSpec, r as QueryOptions, s as formatTable, t as Yamlite, u as generateConfig, v as WatchHandlers, w as CONFIG_NAMES, x as TableResult, y as WatchOptions, z as FieldChange } from "./index-CDLxve55.mjs";
1
+ import { A as parseConfig, B as HistoryEntry, C as Decision, D as OpenOptions, E as DeclaredTable, F as ColumnType, G as Rec, H as IndexSpec, I as ConflictInfo, J as ViewRecord, K as SchemaChange, L as DbRow, M as Capture, N as Change, O as TableInput, P as ChangeOp, R as DbValue, S as BaseHashes, T as Config, U as PageAccess, V as HistoryPage, W as PageSpec, _ as check, a as formatCsv, b as Watcher, c as query, d as init, f as ExportOptions, g as CheckTable, h as CheckResult, i as QueryResult, j as readsFile, k as TableLocation, l as InitOptions, m as exportSite, n as open, o as formatJson, p as ExportResult, q as TableSpec, r as QueryOptions, s as formatTable, t as Yamlite, u as generateConfig, v as WatchHandlers, w as CONFIG_NAMES, x as TableResult, y as WatchOptions, z as FieldChange } from "./index-Bd2ctbJm.mjs";
2
2
  import { DatabaseSync } from "node:sqlite";
3
3
  import { LanguageModel, ModelMessage } from "ai";
4
4
  //#region src/store.d.ts
@@ -59,7 +59,7 @@ interface PageRules {
59
59
  access: Record<string, PageAccess>;
60
60
  sql: boolean;
61
61
  network: string[];
62
- files: string[];
62
+ files: Record<string, PageAccess>;
63
63
  ai: {
64
64
  maxOutputTokens: number;
65
65
  } | null;
@@ -67,7 +67,7 @@ interface PageRules {
67
67
  export declare const DENIED_FILES: string[];
68
68
  export declare const MAX_PAGE_FILE_BYTES = 10485760;
69
69
  export declare function normalizeFilePath(path: string): string | null;
70
- export declare const fileGranted: (files: string[], path: string) => boolean;
70
+ export declare const fileGranted: (files: Record<string, PageAccess>, path: string) => boolean;
71
71
  //#endregion
72
72
  //#region src/agent/providers.d.ts
73
73
  interface Credentials {
@@ -349,6 +349,10 @@ type ServeEvent = {
349
349
  type: "page";
350
350
  at: string;
351
351
  pages: string[];
352
+ } | {
353
+ type: "files";
354
+ at: string;
355
+ paths: string[];
352
356
  };
353
357
  //#endregion
354
358
  //#region src/serve/events.d.ts
@@ -364,6 +368,7 @@ declare class EventHub {
364
368
  warnings(): Record<string, string[]>;
365
369
  configError(): string | null;
366
370
  pagesChanged(pages: string[]): void;
371
+ filesChanged(paths: string[]): void;
367
372
  stream(signal: AbortSignal): ReadableStream<Uint8Array>;
368
373
  close(): void;
369
374
  private onSync;
@@ -520,10 +525,13 @@ interface ApiRequest {
520
525
  type Handler = (req: ApiRequest) => unknown;
521
526
  declare class Router {
522
527
  private readonly routes;
523
- add(method: string, pattern: string, handler: Handler): this;
528
+ add(method: string, pattern: string, handler: Handler, opts?: {
529
+ maxBody?: number;
530
+ }): this;
524
531
  match(method: string, pathname: string): {
525
532
  handler: Handler;
526
533
  params: Record<string, string>;
534
+ maxBody: number;
527
535
  } | "method" | null;
528
536
  dispatch(request: Request): Promise<Response>;
529
537
  }
package/dist/core.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { B as check, C as describeChange, J as fileGranted, K as DENIED_FILES, L as HttpError, T as worthShowing, U as parseConfig, V as CONFIG_NAMES, W as readsFile, Y as normalizeFilePath, a as query, c as exportSite, f as ReviewRefused, h as templatePath, i as formatTable, n as formatCsv, o as generateConfig, q as MAX_PAGE_FILE_BYTES, r as formatJson, s as init, t as open, w as fieldChanges } from "./src-NOJw_gTE.mjs";
2
- import { t as createWorkspace } from "./workspace-Cp5-yHYp.mjs";
1
+ import { B as check, C as describeChange, K as DENIED_FILES, L as HttpError, T as worthShowing, U as parseConfig, V as CONFIG_NAMES, W as readsFile, X as fileGranted, Z as normalizeFilePath, a as query, c as exportSite, f as ReviewRefused, h as templatePath, i as formatTable, n as formatCsv, o as generateConfig, q as MAX_PAGE_FILE_BYTES, r as formatJson, s as init, t as open, w as fieldChanges } from "./src-ChJZPkg9.mjs";
2
+ import { t as createWorkspace } from "./workspace-D7xHlBPc.mjs";
3
3
  export { CONFIG_NAMES, DENIED_FILES, HttpError, MAX_PAGE_FILE_BYTES, ReviewRefused, check, createWorkspace, describeChange, exportSite, fieldChanges, fileGranted, formatCsv, formatJson, formatTable, generateConfig, init, normalizeFilePath, open, parseConfig, query, readsFile, templatePath, worthShowing };
@@ -81,7 +81,7 @@ interface PageSpec {
81
81
  access: Record<string, PageAccess>;
82
82
  sql: boolean;
83
83
  network: string[];
84
- files: string[];
84
+ files: Record<string, PageAccess>;
85
85
  ai: {
86
86
  maxOutputTokens: number;
87
87
  } | null;
package/dist/index.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { C as Decision, D as OpenOptions, F as ColumnType, H as IndexSpec, I as ConflictInfo, K as SchemaChange, N as Change, O as TableInput, P as ChangeOp, W as PageSpec, _ as check, a as formatCsv, b as Watcher, c as query, d as init, f as ExportOptions, g as CheckTable, h as CheckResult, i as QueryResult, l as InitOptions, m as exportSite, n as open, o as formatJson, p as ExportResult, q as TableSpec, r as QueryOptions, s as formatTable, t as Yamlite, u as generateConfig, v as WatchHandlers, x as TableResult, y as WatchOptions } from "./index-CDLxve55.mjs";
1
+ import { C as Decision, D as OpenOptions, F as ColumnType, H as IndexSpec, I as ConflictInfo, K as SchemaChange, N as Change, O as TableInput, P as ChangeOp, W as PageSpec, _ as check, a as formatCsv, b as Watcher, c as query, d as init, f as ExportOptions, g as CheckTable, h as CheckResult, i as QueryResult, l as InitOptions, m as exportSite, n as open, o as formatJson, p as ExportResult, q as TableSpec, r as QueryOptions, s as formatTable, t as Yamlite, u as generateConfig, v as WatchHandlers, x as TableResult, y as WatchOptions } from "./index-Bd2ctbJm.mjs";
2
2
  export { type Change, type ChangeOp, type CheckResult, type CheckTable, type ColumnType, type ConflictInfo, type Decision, type ExportOptions, type ExportResult, type IndexSpec, type InitOptions, type OpenOptions, type PageSpec, type QueryOptions, type QueryResult, type SchemaChange, type TableInput, type TableResult, type TableSpec, type WatchHandlers, type WatchOptions, type Watcher, Yamlite, check, exportSite, formatCsv, formatJson, formatTable, generateConfig, init, open, query };
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- export { B as check, c as exportSite, n as formatCsv, r as formatJson, i as formatTable, o as generateConfig, s as init, t as open, a as query } from "./src-NOJw_gTE.mjs";
1
+ export { B as check, c as exportSite, n as formatCsv, r as formatJson, i as formatTable, o as generateConfig, s as init, t as open, a as query } from "./src-ChJZPkg9.mjs";
@@ -1129,8 +1129,10 @@ function normalizeFilePath(path) {
1129
1129
  if (kept.length === 0) return null;
1130
1130
  return kept.join("/") + (path.endsWith("/") ? "/" : "");
1131
1131
  }
1132
- const fileGranted = (files, path) => files.some((entry) => entry.endsWith("/") ? path.startsWith(entry) : path === entry);
1133
- const deniedFile = (path, denied) => fileGranted(denied.map((d) => d.toLowerCase()), path.toLowerCase());
1132
+ const entryCovers = (entry, path) => entry.endsWith("/") ? path.startsWith(entry) : path === entry;
1133
+ const fileGranted = (files, path) => Object.keys(files).some((entry) => entryCovers(entry, path));
1134
+ const fileWritable = (files, path) => Object.entries(files).some(([entry, level]) => level === "write" && entryCovers(entry, path));
1135
+ const deniedFile = (path, denied) => denied.some((d) => entryCovers(d.toLowerCase(), path.toLowerCase()));
1134
1136
  const CONTENT_TYPES = {
1135
1137
  png: "image/png",
1136
1138
  jpg: "image/jpeg",
@@ -1141,6 +1143,50 @@ const CONTENT_TYPES = {
1141
1143
  pdf: "application/pdf",
1142
1144
  txt: "text/plain"
1143
1145
  };
1146
+ const starts = (b, magic) => magic.every((m, i) => b[i] === m);
1147
+ const WRITABLE_TYPES = {
1148
+ "image/png": (b) => starts(b, [
1149
+ 137,
1150
+ 80,
1151
+ 78,
1152
+ 71,
1153
+ 13,
1154
+ 10,
1155
+ 26,
1156
+ 10
1157
+ ]),
1158
+ "image/jpeg": (b) => starts(b, [
1159
+ 255,
1160
+ 216,
1161
+ 255
1162
+ ]),
1163
+ "image/gif": (b) => starts(b, [
1164
+ 71,
1165
+ 73,
1166
+ 70,
1167
+ 56
1168
+ ]),
1169
+ "image/webp": (b) => starts(b, [
1170
+ 82,
1171
+ 73,
1172
+ 70,
1173
+ 70
1174
+ ]) && starts(b.subarray(8), [
1175
+ 87,
1176
+ 69,
1177
+ 66,
1178
+ 80
1179
+ ]),
1180
+ "application/pdf": (b) => starts(b, [
1181
+ 37,
1182
+ 80,
1183
+ 68,
1184
+ 70,
1185
+ 45
1186
+ ])
1187
+ };
1188
+ const writableType = (path) => Object.hasOwn(WRITABLE_TYPES, contentTypeOf(path));
1189
+ const bytesMatchType = (path, bytes) => WRITABLE_TYPES[contentTypeOf(path)]?.(bytes) ?? false;
1144
1190
  function contentTypeOf(path) {
1145
1191
  const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
1146
1192
  return path.includes(".") && Object.hasOwn(CONTENT_TYPES, ext) ? CONTENT_TYPES[ext] : "application/octet-stream";
@@ -1686,14 +1732,18 @@ function toOrigin(where, raw) {
1686
1732
  return url.origin;
1687
1733
  }
1688
1734
  function toFiles(where, raw) {
1689
- if (raw === void 0 || raw === null) return [];
1690
- if (!Array.isArray(raw)) throw new Error(`${where}files must be a list of root-relative files and folders`);
1691
- return raw.map((entry) => {
1735
+ if (raw === void 0 || raw === null) return {};
1736
+ if (typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${where}files must map root-relative files and folders to read or write`);
1737
+ const out = {};
1738
+ for (const [entry, level] of Object.entries(raw)) {
1692
1739
  const invalid = (why) => /* @__PURE__ */ new Error(`${where}files entry ${JSON.stringify(entry)} ${why}`);
1693
- if (typeof entry !== "string" || normalizeFilePath(entry) !== entry) throw invalid("must be a root-relative path without .., . or empty segments");
1740
+ if (normalizeFilePath(entry) !== entry) throw invalid("must be a root-relative path without .., . or empty segments");
1694
1741
  if (deniedFile(entry, DENIED_FILES)) throw invalid("is YAMLite's own and cannot be granted");
1695
- return entry;
1696
- });
1742
+ if (level !== "read" && level !== "write") throw invalid("must be read or write");
1743
+ if (level === "write" && !entry.endsWith("/") && !writableType(entry)) throw invalid("cannot be write: a page may write only PNG, JPEG, GIF, WebP or PDF files");
1744
+ out[entry] = level;
1745
+ }
1746
+ return out;
1697
1747
  }
1698
1748
  function toPageAi(where, raw) {
1699
1749
  if (raw === void 0 || raw === null || raw === false) return null;
@@ -3624,13 +3674,15 @@ var Reply = class {
3624
3674
  this.body = body;
3625
3675
  }
3626
3676
  };
3677
+ const MAX_BODY = 1048576;
3627
3678
  var Router = class {
3628
3679
  routes = [];
3629
- add(method, pattern, handler) {
3680
+ add(method, pattern, handler, opts = {}) {
3630
3681
  this.routes.push({
3631
3682
  method,
3632
3683
  parts: pattern.split("/").filter(Boolean),
3633
- handler
3684
+ handler,
3685
+ maxBody: opts.maxBody ?? MAX_BODY
3634
3686
  });
3635
3687
  return this;
3636
3688
  }
@@ -3656,7 +3708,8 @@ var Router = class {
3656
3708
  }
3657
3709
  return {
3658
3710
  handler: route.handler,
3659
- params
3711
+ params,
3712
+ maxBody: route.maxBody
3660
3713
  };
3661
3714
  }
3662
3715
  return wrongMethod ? "method" : null;
@@ -3670,7 +3723,7 @@ var Router = class {
3670
3723
  let body;
3671
3724
  if (request.method !== "GET" && request.method !== "HEAD") {
3672
3725
  if (!/^application\/json\s*(;|$)/i.test(request.headers.get("content-type") ?? "")) throw new HttpError(415, "content-type must be application/json");
3673
- body = await readBody(request);
3726
+ body = await readBody(request, match.maxBody);
3674
3727
  }
3675
3728
  const out = await match.handler({
3676
3729
  params: match.params,
@@ -3707,10 +3760,9 @@ function sendJson(res, status, body) {
3707
3760
  });
3708
3761
  res.end(JSON.stringify(body, jsonReplacer$1));
3709
3762
  }
3710
- const MAX_BODY = 1048576;
3711
- async function readBody(request) {
3763
+ async function readBody(request, max) {
3712
3764
  const tooLarge = () => new HttpError(413, "request body is too large");
3713
- if (Number(request.headers.get("content-length")) > MAX_BODY) throw tooLarge();
3765
+ if (Number(request.headers.get("content-length")) > max) throw tooLarge();
3714
3766
  const chunks = [];
3715
3767
  let size = 0;
3716
3768
  if (request.body) {
@@ -3719,7 +3771,7 @@ async function readBody(request) {
3719
3771
  const { value, done } = await reader.read();
3720
3772
  if (done) break;
3721
3773
  size += value.length;
3722
- if (size > MAX_BODY) {
3774
+ if (size > max) {
3723
3775
  await reader.cancel().catch(() => {});
3724
3776
  throw tooLarge();
3725
3777
  }
@@ -4397,6 +4449,14 @@ var EventHub = class {
4397
4449
  };
4398
4450
  for (const res of this.clients.keys()) this.send(res, event.type, event);
4399
4451
  }
4452
+ filesChanged(paths) {
4453
+ const event = {
4454
+ type: "files",
4455
+ at: now(),
4456
+ paths
4457
+ };
4458
+ for (const res of this.clients.keys()) this.send(res, event.type, event);
4459
+ }
4400
4460
  stream(signal) {
4401
4461
  let ctrl;
4402
4462
  const drop = () => {
@@ -5015,17 +5075,13 @@ const rowRoutes = (router, ctx) => {
5015
5075
  const key = params.key;
5016
5076
  const b = objectBody(body, "body");
5017
5077
  const values = objectBody(b.values, "values");
5018
- const base = objectBody(b.base, "base");
5078
+ const base = baseOf$1(b.base);
5019
5079
  noKeyIn(spec, values, "rename the record instead");
5020
5080
  const { store } = ctx;
5021
5081
  writeTx(store, () => {
5022
5082
  const current = readRecord(store, spec, key);
5023
5083
  if (!current) throw new HttpError(404, `no record "${key}" in ${spec.name}`);
5024
- const stale = Object.keys(values).filter((f) => canonical(own$1(current, f) ?? null) !== canonical(own$1(base, f) ?? null));
5025
- if (stale.length > 0) throw new HttpError(409, `changed since it was loaded: ${stale.join(", ")}`, {
5026
- current,
5027
- stale
5028
- });
5084
+ if (base) assertFresh(current, base, Object.keys(values));
5029
5085
  updateRecord(store, spec, key, values);
5030
5086
  });
5031
5087
  return { ok: true };
@@ -5046,13 +5102,31 @@ const rowRoutes = (router, ctx) => {
5046
5102
  });
5047
5103
  return { key: to };
5048
5104
  });
5049
- router.add("DELETE", "/api/tables/:table/rows/:key", ({ params }) => {
5105
+ router.add("DELETE", "/api/tables/:table/rows/:key", ({ params, body }) => {
5050
5106
  ctx.proposals.assertIdle();
5051
5107
  const spec = tableSpec(ctx, params.table);
5052
- writeTx(ctx.store, () => deleteRecord(ctx.store, spec, params.key));
5108
+ const key = params.key;
5109
+ const base = baseOf$1(body?.base);
5110
+ const { store } = ctx;
5111
+ writeTx(store, () => {
5112
+ if (base) {
5113
+ const current = readRecord(store, spec, key);
5114
+ if (!current) throw new HttpError(404, `no record "${key}" in ${spec.name}`);
5115
+ assertFresh(current, base, Object.keys(base));
5116
+ }
5117
+ deleteRecord(store, spec, key);
5118
+ });
5053
5119
  return { ok: true };
5054
5120
  });
5055
5121
  };
5122
+ const baseOf$1 = (value) => value === void 0 ? null : objectBody(value, "base");
5123
+ function assertFresh(current, base, fields) {
5124
+ const stale = fields.filter((f) => canonical(own$1(current, f) ?? null) !== canonical(own$1(base, f) ?? null));
5125
+ if (stale.length > 0) throw new HttpError(409, `changed since it was loaded: ${stale.join(", ")}`, {
5126
+ current,
5127
+ stale
5128
+ });
5129
+ }
5056
5130
  //#endregion
5057
5131
  //#region src/serve/routes/history.ts
5058
5132
  const CURSOR = /^\d+$/;
@@ -5676,7 +5750,7 @@ async function gitStatus(repo) {
5676
5750
  }
5677
5751
  async function gitDiff(repo, paths = []) {
5678
5752
  const scope = paths.map((p) => {
5679
- const top = insideRoot(repo, p);
5753
+ const top = insideRoot$1(repo, p);
5680
5754
  if (top === null) throw new Error(`${p} must be inside the data folder`);
5681
5755
  return top;
5682
5756
  });
@@ -5737,7 +5811,7 @@ async function ghReady(env = process.env) {
5737
5811
  return false;
5738
5812
  }
5739
5813
  }
5740
- function insideRoot(repo, path) {
5814
+ function insideRoot$1(repo, path) {
5741
5815
  const abs = resolve(repo.root, path);
5742
5816
  const rel = relative(repo.root, abs);
5743
5817
  if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
@@ -5855,7 +5929,7 @@ async function branchName(repo, v, i) {
5855
5929
  }
5856
5930
  async function commitPath(repo, v, i) {
5857
5931
  const p = text$1(v, "path", i);
5858
- const top = insideRoot(repo, p);
5932
+ const top = insideRoot$1(repo, p);
5859
5933
  if (top === null) throw new Error(`step ${i + 1}: ${p} must be inside the data folder`);
5860
5934
  if (existsSync(resolve(repo.root, p))) return p;
5861
5935
  try {
@@ -6003,7 +6077,7 @@ async function runOne(repo, s, gh) {
6003
6077
  await g(["switch", s.branch]);
6004
6078
  break;
6005
6079
  case "commit": {
6006
- const tops = s.paths.map((p) => insideRoot(repo, p));
6080
+ const tops = s.paths.map((p) => insideRoot$1(repo, p));
6007
6081
  await g([
6008
6082
  "add",
6009
6083
  "-A",
@@ -6241,7 +6315,7 @@ async function localDriver(root, gh) {
6241
6315
  changes: []
6242
6316
  };
6243
6317
  const s = await gitStatus(f.repo);
6244
- const changes = s.changes.filter((c) => insideRoot(f.repo, c.path) !== null).map((c) => ({
6318
+ const changes = s.changes.filter((c) => insideRoot$1(f.repo, c.path) !== null).map((c) => ({
6245
6319
  path: c.path,
6246
6320
  status: changeStatus(c.status)
6247
6321
  }));
@@ -6254,7 +6328,7 @@ async function localDriver(root, gh) {
6254
6328
  defaultBranch: defaultName,
6255
6329
  async baseContent(rootPath) {
6256
6330
  const { repo } = await need();
6257
- const top = insideRoot(repo, rootPath);
6331
+ const top = insideRoot$1(repo, rootPath);
6258
6332
  if (top === null) return null;
6259
6333
  try {
6260
6334
  return await runGit(repo.top, ["show", `HEAD:${top}`]);
@@ -6342,7 +6416,7 @@ async function defaultRef(repo, name) {
6342
6416
  throw new GitError(`the default branch ${name} is not in this clone`);
6343
6417
  }
6344
6418
  async function blobAt(repo, rev, rootPath) {
6345
- const top = insideRoot(repo, rootPath);
6419
+ const top = insideRoot$1(repo, rootPath);
6346
6420
  if (top === null) throw new GitError(`${rootPath} is outside the repository`);
6347
6421
  let entry;
6348
6422
  try {
@@ -6621,7 +6695,90 @@ function grantedFile(root, files, denied, path) {
6621
6695
  size
6622
6696
  };
6623
6697
  }
6624
- const readGranted = (ctx, page, path) => readFileSync(grantedFile(ctx.root, page.files, ctx.deniedFiles, path).real);
6698
+ const MAX_WRITE_BODY = Math.ceil(MAX_PAGE_FILE_BYTES / 3) * 4 + 4096;
6699
+ const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
6700
+ const insideRoot = (rel) => rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
6701
+ function writeTarget(root, page, denied, path) {
6702
+ const realRoot = realpathSync.native(root);
6703
+ const parts = path.split("/");
6704
+ let at = realRoot;
6705
+ let i = 0;
6706
+ for (; i < parts.length - 1; i++) {
6707
+ const next = join(at, parts[i]);
6708
+ try {
6709
+ at = realpathSync.native(next);
6710
+ } catch {
6711
+ if (lstatOrNull(next)) throw new HttpError(409, `${parts.slice(0, i + 1).join("/")} is not a folder`);
6712
+ break;
6713
+ }
6714
+ if (!statSync(at).isDirectory()) throw new HttpError(409, `${parts.slice(0, i + 1).join("/")} is not a folder`);
6715
+ }
6716
+ const target = join(at, ...parts.slice(i));
6717
+ const rel = relative(realRoot, target);
6718
+ const shown = rel.split(sep).join("/");
6719
+ if (!insideRoot(rel) || !fileWritable(page.files, shown) || deniedFile(shown, denied)) throw new HttpError(403, `${path} leads outside the files this page may write`);
6720
+ let isLink = false;
6721
+ try {
6722
+ const st = lstatSync(target);
6723
+ isLink = st.isSymbolicLink();
6724
+ if (!isLink && !st.isFile()) throw new HttpError(409, `${path} is not a file`);
6725
+ } catch (e) {
6726
+ if (e instanceof HttpError) throw e;
6727
+ }
6728
+ if (isLink) throw new HttpError(403, `${path} is a symlink; a page may not write through it`);
6729
+ return target;
6730
+ }
6731
+ const lstatOrNull = (path) => lstatSync(path, { throwIfNoEntry: false }) ?? null;
6732
+ function readIfFile(path) {
6733
+ const st = lstatOrNull(path);
6734
+ if (!st?.isFile()) return null;
6735
+ if (st.size > 10485760) return "too-large";
6736
+ return readFileSync(path);
6737
+ }
6738
+ function baseOf(body) {
6739
+ const base = body?.base;
6740
+ if (base === void 0 || base === null) return base;
6741
+ if (typeof base !== "string" || !/^[0-9a-f]{64}$/.test(base)) throw new HttpError(400, "base must be a SHA-256 hex digest or null");
6742
+ return base;
6743
+ }
6744
+ async function hostedFile(ctx, target, path, signal) {
6745
+ if (existsSync(target)) return null;
6746
+ const hosted = await ctx.pageFiles?.(path, signal);
6747
+ return hosted === void 0 || hosted === "missing" ? null : hosted;
6748
+ }
6749
+ function checkBase(target, path, base, hosted) {
6750
+ const disk = readIfFile(target);
6751
+ const current = disk ?? hosted;
6752
+ const from = disk !== null ? "disk" : hosted !== null ? "host" : null;
6753
+ if (base === void 0) return from;
6754
+ if (current === "too-large") throw new HttpError(409, `${path} changed since the page read it`);
6755
+ const now = current === null ? null : hashContent(current);
6756
+ if (now !== base) throw new HttpError(409, `${path} changed since the page read it`, { current: now });
6757
+ return from;
6758
+ }
6759
+ function writableRequest(ctx, params, query) {
6760
+ const page = pageSpec(ctx, params.page);
6761
+ const asked = query.get("path");
6762
+ if (!asked) throw new HttpError(400, "path is required");
6763
+ const path = normalizeFilePath(asked);
6764
+ if (path === null || path.endsWith("/") || !fileWritable(page.files, path)) throw new HttpError(403, `${asked} is not in the files this page may write`);
6765
+ if (deniedFile(path, ctx.deniedFiles)) throw new HttpError(403, `${asked} is YAMLite's own and no page may write it`);
6766
+ return {
6767
+ page,
6768
+ path
6769
+ };
6770
+ }
6771
+ function replaceFile(target, bytes) {
6772
+ mkdirSync(dirname(target), { recursive: true });
6773
+ const tmp = join(dirname(target), `.yamlite-${crypto.randomUUID()}.tmp`);
6774
+ try {
6775
+ writeFileSync(tmp, bytes);
6776
+ renameSync(tmp, target);
6777
+ } catch (e) {
6778
+ rmSync(tmp, { force: true });
6779
+ throw e;
6780
+ }
6781
+ }
6625
6782
  const pageRoutes = (router, ctx) => {
6626
6783
  router.add("GET", "/api/pages/:page/html", ({ params }) => {
6627
6784
  const page = pageSpec(ctx, params.page);
@@ -6634,15 +6791,47 @@ const pageRoutes = (router, ctx) => {
6634
6791
  const path = normalizeFilePath(asked);
6635
6792
  if (path === null || !fileGranted(page.files, path)) throw new HttpError(403, `${asked} is not in this page's files`);
6636
6793
  if (deniedFile(path, ctx.deniedFiles)) throw new HttpError(403, `${asked} is YAMLite's own and no page may read it`);
6637
- let bytes = await ctx.pageFiles?.(path, signal);
6638
- if (bytes === "missing") throw missing(path);
6639
- if (bytes === "too-large" || bytes !== void 0 && bytes.length > 10485760) throw tooLarge(path);
6640
- bytes ??= readGranted(ctx, page, path);
6794
+ let bytes;
6795
+ try {
6796
+ bytes = readFileSync(grantedFile(ctx.root, page.files, ctx.deniedFiles, path).real);
6797
+ } catch (e) {
6798
+ if (!(e instanceof HttpError) || e.status !== 404) throw e;
6799
+ const hosted = await ctx.pageFiles?.(path, signal);
6800
+ if (hosted === void 0 || hosted === "missing") throw missing(path);
6801
+ if (hosted === "too-large" || hosted.length > 10485760) throw tooLarge(path);
6802
+ bytes = hosted;
6803
+ }
6641
6804
  return new Response(bytes, { headers: {
6642
6805
  ...FILE_HEADERS,
6643
6806
  "content-type": contentTypeOf(path)
6644
6807
  } });
6645
6808
  });
6809
+ router.add("PUT", "/api/pages/:page/file", async ({ params, query, body, signal }) => {
6810
+ const { page, path } = writableRequest(ctx, params, query);
6811
+ if (!writableType(path)) throw new HttpError(415, `${path}: a page may write only PNG, JPEG, GIF, WebP or PDF files`);
6812
+ const data = body?.data;
6813
+ if (typeof data !== "string" || !BASE64.test(data)) throw new HttpError(400, "data must be base64");
6814
+ const bytes = Buffer.from(data, "base64");
6815
+ if (bytes.length > 10485760) throw tooLarge(path);
6816
+ if (!bytesMatchType(path, bytes)) throw new HttpError(415, `${path}: the content is not ${contentTypeOf(path)}`);
6817
+ const base = baseOf(body);
6818
+ const hosted = await hostedFile(ctx, writeTarget(ctx.root, page, ctx.deniedFiles, path), path, signal);
6819
+ const target = writeTarget(ctx.root, page, ctx.deniedFiles, path);
6820
+ checkBase(target, path, base, hosted);
6821
+ replaceFile(target, bytes);
6822
+ return { sha256: hashContent(bytes) };
6823
+ }, { maxBody: MAX_WRITE_BODY });
6824
+ router.add("DELETE", "/api/pages/:page/file", async ({ params, query, body, signal }) => {
6825
+ const { page, path } = writableRequest(ctx, params, query);
6826
+ const base = baseOf(body);
6827
+ const hosted = await hostedFile(ctx, writeTarget(ctx.root, page, ctx.deniedFiles, path), path, signal);
6828
+ const target = writeTarget(ctx.root, page, ctx.deniedFiles, path);
6829
+ const from = checkBase(target, path, base, hosted);
6830
+ if (from === null) throw missing(path);
6831
+ if (from === "host") throw new HttpError(409, `${path} is kept by the host and cannot be deleted here`);
6832
+ unlinkSync(target);
6833
+ return { ok: true };
6834
+ });
6646
6835
  };
6647
6836
  //#endregion
6648
6837
  //#region src/serve/routes/pageai.ts
@@ -7725,7 +7914,7 @@ function grantedCandidates(root, entries) {
7725
7914
  return found;
7726
7915
  }
7727
7916
  function copyPageFiles(root, dir, grants, denied, skipped) {
7728
- for (const path of new Set(grantedCandidates(root, grants))) {
7917
+ for (const path of new Set(grantedCandidates(root, Object.keys(grants)))) {
7729
7918
  if (deniedFile(path, denied)) continue;
7730
7919
  let real;
7731
7920
  try {
@@ -7808,7 +7997,7 @@ async function writeSnapshotData(opts) {
7808
7997
  }
7809
7998
  }
7810
7999
  const kept = new Set(pages.map(([name]) => name));
7811
- copyPageFiles(root, opts.dir, y.pages.filter((p) => kept.has(p.name)).flatMap((p) => p.files), ctx.deniedFiles, skippedFiles);
8000
+ copyPageFiles(root, opts.dir, Object.assign({}, ...y.pages.filter((p) => kept.has(p.name)).map((p) => p.files)), ctx.deniedFiles, skippedFiles);
7812
8001
  meta = {
7813
8002
  ...served,
7814
8003
  root: basename(root),
@@ -8574,4 +8763,4 @@ async function open(options) {
8574
8763
  };
8575
8764
  }
8576
8765
  //#endregion
8577
- export { deniedFiles as A, check as B, describeChange as C, isRead as D, PageSql as E, Router as F, resolveConfig as G, configPath as H, createHandler as I, fileGranted as J, DENIED_FILES as K, HttpError as L, readRecord as M, assertSynced as N, statementCount as O, toWire as P, COLUMN_TYPES as Q, isLoopback as R, which as S, worthShowing as T, parseConfig as U, CONFIG_NAMES as V, readsFile as W, declaredViews as X, normalizeFilePath as Y, Store as Z, ghReady as _, query as a, gitLog as b, exportSite as c, validateNewTable as d, ReviewRefused as f, DIFF_LIMIT as g, templatePath as h, formatTable as i, ProposalStore as j, EventHub as k, ROUTES as l, STEP_KINDS as m, formatCsv as n, generateConfig as o, localDriver as p, MAX_PAGE_FILE_BYTES as q, formatJson as r, init as s, open as t, createTable as u, gitBranches as v, fieldChanges as w, gitStatus as x, gitDiff as y, loopbackHosts as z };
8766
+ export { Store as $, deniedFiles as A, check as B, describeChange as C, isRead as D, PageSql as E, Router as F, resolveConfig as G, configPath as H, createHandler as I, deniedFile as J, DENIED_FILES as K, HttpError as L, readRecord as M, assertSynced as N, statementCount as O, toWire as P, declaredViews as Q, isLoopback as R, which as S, worthShowing as T, parseConfig as U, CONFIG_NAMES as V, readsFile as W, fileGranted as X, entryCovers as Y, normalizeFilePath as Z, ghReady as _, query as a, gitLog as b, exportSite as c, validateNewTable as d, COLUMN_TYPES as et, ReviewRefused as f, DIFF_LIMIT as g, templatePath as h, formatTable as i, ProposalStore as j, EventHub as k, ROUTES as l, STEP_KINDS as m, formatCsv as n, generateConfig as o, localDriver as p, MAX_PAGE_FILE_BYTES as q, formatJson as r, init as s, open as t, createTable as u, gitBranches as v, fieldChanges as w, gitStatus as x, gitDiff as y, loopbackHosts as z };