yamlite 0.39.0 → 0.41.0

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.
Files changed (32) hide show
  1. package/README.md +21 -15
  2. package/dist/browser/chokidar.mjs +1 -1
  3. package/dist/browser/expose.mjs +10 -17
  4. package/dist/browser/fs.d.mts +5 -0
  5. package/dist/browser/fs.mjs +1 -1
  6. package/dist/cli.mjs +3 -2
  7. package/dist/core.d.mts +31 -14
  8. package/dist/core.mjs +3 -3
  9. package/dist/{fs-DZ4QHM93.mjs → fs-BM30qNPb.mjs} +8 -1
  10. package/dist/{index-OSyELQEX.d.mts → index-Bd2ctbJm.d.mts} +2 -0
  11. package/dist/index.d.mts +1 -1
  12. package/dist/index.mjs +1 -1
  13. package/dist/{src-BMrfSYwa.mjs → src-ChJZPkg9.mjs} +376 -28
  14. package/dist/ui/assets/{ErdView-D3Q6HCAZ.js → ErdView-CWmAQgo6.js} +1 -1
  15. package/dist/ui/assets/{MarkdownField-Dh4Tn8_S.js → MarkdownField-DI3OMg7I.js} +1 -1
  16. package/dist/ui/assets/PageView-BsEhcE48.js +1 -0
  17. package/dist/ui/assets/access-DV6K5419.js +1 -0
  18. package/dist/ui/assets/index-emolmw-f.js +126 -0
  19. package/dist/ui/assets/load-BsZuQtRZ.js +2 -0
  20. package/dist/ui/index.html +1 -1
  21. package/dist/ui-embed/{ErdView-Dk-iexgy.js → ErdView-CmUxHybj.js} +2 -2
  22. package/dist/ui-embed/{HintPopover-D5P2UuTa.js → HintPopover-NOWaUpA_.js} +737 -721
  23. package/dist/ui-embed/PageView-Bptfb1IR.js +1154 -0
  24. package/dist/ui-embed/{activity-letLdXN4.js → activity-v3qF9H6V.js} +1 -1
  25. package/dist/ui-embed/embed.js +36 -36
  26. package/dist/ui-embed/{pageFrame-CFoU6UjD.js → pageFrame-vCls4cAs.js} +1 -1
  27. package/dist/{workspace-gtZ3m7LH.mjs → workspace-D7xHlBPc.mjs} +78 -6
  28. package/package.json +1 -1
  29. package/dist/ui/assets/PageView-9PohkcTy.js +0 -1
  30. package/dist/ui/assets/index-nxlI4YiX.js +0 -126
  31. package/dist/ui/assets/load-C3NfhY_i.js +0 -2
  32. package/dist/ui-embed/PageView-BzCYlXPg.js +0 -818
@@ -1,5 +1,5 @@
1
1
  import { basename, delimiter, dirname, extname, isAbsolute, join, matchesGlob, normalize, posix, relative, resolve, sep } from "node:path";
2
- import { accessSync, chmodSync, constants, cpSync, createReadStream, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { accessSync, chmodSync, constants, copyFileSync, cpSync, createReadStream, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { Document, Scalar, isMap, isNode, isScalar, isSeq, parse, parseDocument, stringify } from "yaml";
5
5
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
@@ -1105,6 +1105,12 @@ function reconcileViews(store, spec, apply) {
1105
1105
  }
1106
1106
  //#endregion
1107
1107
  //#region src/pages/access.ts
1108
+ const DENIED_FILES = [
1109
+ ".yamlite/",
1110
+ "yamlite.yaml",
1111
+ "yamlite.yml"
1112
+ ];
1113
+ const MAX_PAGE_FILE_BYTES = 10485760;
1108
1114
  const PAGE_AI_DEFAULT_TOKENS = 1024;
1109
1115
  const PAGE_AI_MAX_TOKENS = 8192;
1110
1116
  const canRead = (page, name) => Object.hasOwn(page.access, name);
@@ -1115,6 +1121,76 @@ function trustedViews(page, views) {
1115
1121
  for (const name of Object.keys(page.access)) for (let v = name; v !== void 0 && parentOf.has(v) && !trusted.has(v); v = parentOf.get(v)) trusted.add(v);
1116
1122
  return trusted;
1117
1123
  }
1124
+ function normalizeFilePath(path) {
1125
+ if (path.startsWith("/") || path.startsWith("\\") || /^[a-z]:/i.test(path)) return null;
1126
+ const parts = path.split(/[\\/]/);
1127
+ if (parts.includes("..")) return null;
1128
+ const kept = parts.filter((p) => p !== "" && p !== ".");
1129
+ if (kept.length === 0) return null;
1130
+ return kept.join("/") + (path.endsWith("/") ? "/" : "");
1131
+ }
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()));
1136
+ const CONTENT_TYPES = {
1137
+ png: "image/png",
1138
+ jpg: "image/jpeg",
1139
+ jpeg: "image/jpeg",
1140
+ webp: "image/webp",
1141
+ gif: "image/gif",
1142
+ svg: "image/svg+xml",
1143
+ pdf: "application/pdf",
1144
+ txt: "text/plain"
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;
1190
+ function contentTypeOf(path) {
1191
+ const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
1192
+ return path.includes(".") && Object.hasOwn(CONTENT_TYPES, ext) ? CONTENT_TYPES[ext] : "application/octet-stream";
1193
+ }
1118
1194
  //#endregion
1119
1195
  //#region src/config.ts
1120
1196
  const CONFIG_NAMES = ["yamlite.yaml", "yamlite.yml"];
@@ -1639,6 +1715,7 @@ const PAGE_KEYS = [
1639
1715
  "access",
1640
1716
  "sql",
1641
1717
  "network",
1718
+ "files",
1642
1719
  "ai"
1643
1720
  ];
1644
1721
  const PAGE_NAME = /^[a-z0-9][a-z0-9_-]*$/;
@@ -1654,6 +1731,20 @@ function toOrigin(where, raw) {
1654
1731
  if (url.protocol !== "https:" && url.protocol !== "http:" || raw.replace(/\/$/, "") !== url.origin) throw invalid();
1655
1732
  return url.origin;
1656
1733
  }
1734
+ function toFiles(where, raw) {
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)) {
1739
+ const invalid = (why) => /* @__PURE__ */ new Error(`${where}files entry ${JSON.stringify(entry)} ${why}`);
1740
+ if (normalizeFilePath(entry) !== entry) throw invalid("must be a root-relative path without .., . or empty segments");
1741
+ if (deniedFile(entry, DENIED_FILES)) throw invalid("is YAMLite's own and cannot be granted");
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;
1747
+ }
1657
1748
  function toPageAi(where, raw) {
1658
1749
  if (raw === void 0 || raw === null || raw === false) return null;
1659
1750
  if (raw === true) return { maxOutputTokens: PAGE_AI_DEFAULT_TOKENS };
@@ -1694,6 +1785,7 @@ function toPages(raw, place) {
1694
1785
  access,
1695
1786
  sql: v.sql === true,
1696
1787
  network: (v.network ?? []).map((o) => toOrigin(where, o)),
1788
+ files: toFiles(where, v.files),
1697
1789
  ai: toPageAi(where, v.ai)
1698
1790
  };
1699
1791
  });
@@ -3582,13 +3674,15 @@ var Reply = class {
3582
3674
  this.body = body;
3583
3675
  }
3584
3676
  };
3677
+ const MAX_BODY = 1048576;
3585
3678
  var Router = class {
3586
3679
  routes = [];
3587
- add(method, pattern, handler) {
3680
+ add(method, pattern, handler, opts = {}) {
3588
3681
  this.routes.push({
3589
3682
  method,
3590
3683
  parts: pattern.split("/").filter(Boolean),
3591
- handler
3684
+ handler,
3685
+ maxBody: opts.maxBody ?? MAX_BODY
3592
3686
  });
3593
3687
  return this;
3594
3688
  }
@@ -3614,7 +3708,8 @@ var Router = class {
3614
3708
  }
3615
3709
  return {
3616
3710
  handler: route.handler,
3617
- params
3711
+ params,
3712
+ maxBody: route.maxBody
3618
3713
  };
3619
3714
  }
3620
3715
  return wrongMethod ? "method" : null;
@@ -3628,7 +3723,7 @@ var Router = class {
3628
3723
  let body;
3629
3724
  if (request.method !== "GET" && request.method !== "HEAD") {
3630
3725
  if (!/^application\/json\s*(;|$)/i.test(request.headers.get("content-type") ?? "")) throw new HttpError(415, "content-type must be application/json");
3631
- body = await readBody(request);
3726
+ body = await readBody(request, match.maxBody);
3632
3727
  }
3633
3728
  const out = await match.handler({
3634
3729
  params: match.params,
@@ -3665,10 +3760,9 @@ function sendJson(res, status, body) {
3665
3760
  });
3666
3761
  res.end(JSON.stringify(body, jsonReplacer$1));
3667
3762
  }
3668
- const MAX_BODY = 1048576;
3669
- async function readBody(request) {
3763
+ async function readBody(request, max) {
3670
3764
  const tooLarge = () => new HttpError(413, "request body is too large");
3671
- if (Number(request.headers.get("content-length")) > MAX_BODY) throw tooLarge();
3765
+ if (Number(request.headers.get("content-length")) > max) throw tooLarge();
3672
3766
  const chunks = [];
3673
3767
  let size = 0;
3674
3768
  if (request.body) {
@@ -3677,7 +3771,7 @@ async function readBody(request) {
3677
3771
  const { value, done } = await reader.read();
3678
3772
  if (done) break;
3679
3773
  size += value.length;
3680
- if (size > MAX_BODY) {
3774
+ if (size > max) {
3681
3775
  await reader.cancel().catch(() => {});
3682
3776
  throw tooLarge();
3683
3777
  }
@@ -4238,6 +4332,37 @@ var ProposalStore = class {
4238
4332
  };
4239
4333
  //#endregion
4240
4334
  //#region src/serve/context.ts
4335
+ function onDisk(path) {
4336
+ try {
4337
+ return realpathSync.native(path);
4338
+ } catch {
4339
+ try {
4340
+ return join(realpathSync.native(dirname(path)), basename(path));
4341
+ } catch {
4342
+ return path;
4343
+ }
4344
+ }
4345
+ }
4346
+ function deniedFiles(root, stateDir, dbPath) {
4347
+ const base = onDisk(root);
4348
+ const inside = (path) => {
4349
+ const rel = relative(base, path);
4350
+ return rel === "" || rel.startsWith("..") || isAbsolute(rel) ? null : rel.split(sep).join("/");
4351
+ };
4352
+ const state = inside(onDisk(stateDir));
4353
+ const given = inside(join(onDisk(dirname(dbPath)), basename(dbPath)));
4354
+ const dbs = [.../* @__PURE__ */ new Set([given, inside(onDisk(dbPath))])].filter((db) => db !== null);
4355
+ return [
4356
+ ...DENIED_FILES,
4357
+ ...state === null ? [] : [`${state}/`],
4358
+ ...dbs.flatMap((db) => [
4359
+ db,
4360
+ `${db}-wal`,
4361
+ `${db}-shm`,
4362
+ `${db}-journal`
4363
+ ])
4364
+ ];
4365
+ }
4241
4366
  function displayPath$1(root, path) {
4242
4367
  const rel = relative(root, path);
4243
4368
  return rel === "" || rel.startsWith("..") ? path : rel;
@@ -4324,6 +4449,14 @@ var EventHub = class {
4324
4449
  };
4325
4450
  for (const res of this.clients.keys()) this.send(res, event.type, event);
4326
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
+ }
4327
4460
  stream(signal) {
4328
4461
  let ctrl;
4329
4462
  const drop = () => {
@@ -4942,17 +5075,13 @@ const rowRoutes = (router, ctx) => {
4942
5075
  const key = params.key;
4943
5076
  const b = objectBody(body, "body");
4944
5077
  const values = objectBody(b.values, "values");
4945
- const base = objectBody(b.base, "base");
5078
+ const base = baseOf$1(b.base);
4946
5079
  noKeyIn(spec, values, "rename the record instead");
4947
5080
  const { store } = ctx;
4948
5081
  writeTx(store, () => {
4949
5082
  const current = readRecord(store, spec, key);
4950
5083
  if (!current) throw new HttpError(404, `no record "${key}" in ${spec.name}`);
4951
- const stale = Object.keys(values).filter((f) => canonical(own$1(current, f) ?? null) !== canonical(own$1(base, f) ?? null));
4952
- if (stale.length > 0) throw new HttpError(409, `changed since it was loaded: ${stale.join(", ")}`, {
4953
- current,
4954
- stale
4955
- });
5084
+ if (base) assertFresh(current, base, Object.keys(values));
4956
5085
  updateRecord(store, spec, key, values);
4957
5086
  });
4958
5087
  return { ok: true };
@@ -4973,13 +5102,31 @@ const rowRoutes = (router, ctx) => {
4973
5102
  });
4974
5103
  return { key: to };
4975
5104
  });
4976
- router.add("DELETE", "/api/tables/:table/rows/:key", ({ params }) => {
5105
+ router.add("DELETE", "/api/tables/:table/rows/:key", ({ params, body }) => {
4977
5106
  ctx.proposals.assertIdle();
4978
5107
  const spec = tableSpec(ctx, params.table);
4979
- 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
+ });
4980
5119
  return { ok: true };
4981
5120
  });
4982
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
+ }
4983
5130
  //#endregion
4984
5131
  //#region src/serve/routes/history.ts
4985
5132
  const CURSOR = /^\d+$/;
@@ -5603,7 +5750,7 @@ async function gitStatus(repo) {
5603
5750
  }
5604
5751
  async function gitDiff(repo, paths = []) {
5605
5752
  const scope = paths.map((p) => {
5606
- const top = insideRoot(repo, p);
5753
+ const top = insideRoot$1(repo, p);
5607
5754
  if (top === null) throw new Error(`${p} must be inside the data folder`);
5608
5755
  return top;
5609
5756
  });
@@ -5664,7 +5811,7 @@ async function ghReady(env = process.env) {
5664
5811
  return false;
5665
5812
  }
5666
5813
  }
5667
- function insideRoot(repo, path) {
5814
+ function insideRoot$1(repo, path) {
5668
5815
  const abs = resolve(repo.root, path);
5669
5816
  const rel = relative(repo.root, abs);
5670
5817
  if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
@@ -5782,7 +5929,7 @@ async function branchName(repo, v, i) {
5782
5929
  }
5783
5930
  async function commitPath(repo, v, i) {
5784
5931
  const p = text$1(v, "path", i);
5785
- const top = insideRoot(repo, p);
5932
+ const top = insideRoot$1(repo, p);
5786
5933
  if (top === null) throw new Error(`step ${i + 1}: ${p} must be inside the data folder`);
5787
5934
  if (existsSync(resolve(repo.root, p))) return p;
5788
5935
  try {
@@ -5930,7 +6077,7 @@ async function runOne(repo, s, gh) {
5930
6077
  await g(["switch", s.branch]);
5931
6078
  break;
5932
6079
  case "commit": {
5933
- const tops = s.paths.map((p) => insideRoot(repo, p));
6080
+ const tops = s.paths.map((p) => insideRoot$1(repo, p));
5934
6081
  await g([
5935
6082
  "add",
5936
6083
  "-A",
@@ -6168,7 +6315,7 @@ async function localDriver(root, gh) {
6168
6315
  changes: []
6169
6316
  };
6170
6317
  const s = await gitStatus(f.repo);
6171
- 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) => ({
6172
6319
  path: c.path,
6173
6320
  status: changeStatus(c.status)
6174
6321
  }));
@@ -6181,7 +6328,7 @@ async function localDriver(root, gh) {
6181
6328
  defaultBranch: defaultName,
6182
6329
  async baseContent(rootPath) {
6183
6330
  const { repo } = await need();
6184
- const top = insideRoot(repo, rootPath);
6331
+ const top = insideRoot$1(repo, rootPath);
6185
6332
  if (top === null) return null;
6186
6333
  try {
6187
6334
  return await runGit(repo.top, ["show", `HEAD:${top}`]);
@@ -6269,7 +6416,7 @@ async function defaultRef(repo, name) {
6269
6416
  throw new GitError(`the default branch ${name} is not in this clone`);
6270
6417
  }
6271
6418
  async function blobAt(repo, rev, rootPath) {
6272
- const top = insideRoot(repo, rootPath);
6419
+ const top = insideRoot$1(repo, rootPath);
6273
6420
  if (top === null) throw new GitError(`${rootPath} is outside the repository`);
6274
6421
  let entry;
6275
6422
  try {
@@ -6520,11 +6667,171 @@ function pageSpec(ctx, name) {
6520
6667
  if (!page) throw new HttpError(404, `unknown page: ${name}`);
6521
6668
  return page;
6522
6669
  }
6670
+ const FILE_HEADERS = {
6671
+ "cache-control": "no-store",
6672
+ "x-content-type-options": "nosniff",
6673
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox"
6674
+ };
6675
+ const tooLarge = (path) => new HttpError(413, `${path} is larger than 10 MB`);
6676
+ const missing = (path) => new HttpError(404, `file not found: ${path}`);
6677
+ function grantedFile(root, files, denied, path) {
6678
+ let real;
6679
+ let size;
6680
+ try {
6681
+ real = realpathSync.native(join(root, path));
6682
+ const st = statSync(real);
6683
+ if (!st.isFile()) throw missing(path);
6684
+ size = st.size;
6685
+ } catch (e) {
6686
+ if (e instanceof HttpError) throw e;
6687
+ throw missing(path);
6688
+ }
6689
+ const rel = relative(realpathSync.native(root), real);
6690
+ const shown = rel.split(sep).join("/");
6691
+ if (rel.startsWith("..") || isAbsolute(rel) || !fileGranted(files, shown) || deniedFile(shown, denied)) throw new HttpError(403, `${path} leads outside this page's files`);
6692
+ if (size > 10485760) throw tooLarge(path);
6693
+ return {
6694
+ real,
6695
+ size
6696
+ };
6697
+ }
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
+ }
6523
6782
  const pageRoutes = (router, ctx) => {
6524
6783
  router.add("GET", "/api/pages/:page/html", ({ params }) => {
6525
6784
  const page = pageSpec(ctx, params.page);
6526
6785
  return { html: readPageHtml(page.path, displayPath$1(ctx.root, page.path)) };
6527
6786
  });
6787
+ router.add("GET", "/api/pages/:page/file", async ({ params, query, signal }) => {
6788
+ const page = pageSpec(ctx, params.page);
6789
+ const asked = query.get("path");
6790
+ if (!asked) throw new HttpError(400, "path is required");
6791
+ const path = normalizeFilePath(asked);
6792
+ if (path === null || !fileGranted(page.files, path)) throw new HttpError(403, `${asked} is not in this page's files`);
6793
+ if (deniedFile(path, ctx.deniedFiles)) throw new HttpError(403, `${asked} is YAMLite's own and no page may read it`);
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
+ }
6804
+ return new Response(bytes, { headers: {
6805
+ ...FILE_HEADERS,
6806
+ "content-type": contentTypeOf(path)
6807
+ } });
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
+ });
6528
6835
  };
6529
6836
  //#endregion
6530
6837
  //#region src/serve/routes/pageai.ts
@@ -7077,6 +7384,7 @@ const metaRoutes = (router, ctx) => {
7077
7384
  access: p.access,
7078
7385
  sql: p.sql,
7079
7386
  network: p.network,
7387
+ files: p.files,
7080
7388
  ai: p.ai
7081
7389
  })),
7082
7390
  views: ctx.y.tables.flatMap((t) => declaredViews(t).map(({ spec, parent, depth, path }) => {
@@ -7586,6 +7894,40 @@ function warningScrubber(root, specPaths) {
7586
7894
  return name === "" ? out : out.split(root).join(name);
7587
7895
  };
7588
7896
  }
7897
+ function grantedCandidates(root, entries) {
7898
+ const found = [];
7899
+ const walk = (rel) => {
7900
+ let items;
7901
+ try {
7902
+ items = readdirSync(join(root, rel), { withFileTypes: true });
7903
+ } catch {
7904
+ return;
7905
+ }
7906
+ for (const item of items) {
7907
+ const path = `${rel}${item.name}`;
7908
+ if (item.isDirectory()) walk(`${path}/`);
7909
+ else found.push(path);
7910
+ }
7911
+ };
7912
+ for (const entry of entries) if (entry.endsWith("/")) walk(entry);
7913
+ else found.push(entry);
7914
+ return found;
7915
+ }
7916
+ function copyPageFiles(root, dir, grants, denied, skipped) {
7917
+ for (const path of new Set(grantedCandidates(root, Object.keys(grants)))) {
7918
+ if (deniedFile(path, denied)) continue;
7919
+ let real;
7920
+ try {
7921
+ real = grantedFile(root, grants, denied, path).real;
7922
+ } catch (e) {
7923
+ if (e instanceof HttpError && e.status === 413) skipped[path] = "is larger than 10 MB";
7924
+ continue;
7925
+ }
7926
+ const dest = join(dir, "data", "files", `${path}.bin`);
7927
+ mkdirSync(dirname(dest), { recursive: true });
7928
+ copyFileSync(real, dest);
7929
+ }
7930
+ }
7589
7931
  const writeJson = (path, value) => writeFileSync(path, JSON.stringify(value));
7590
7932
  async function writeSnapshotData(opts) {
7591
7933
  const root = resolve(opts.root);
@@ -7597,6 +7939,7 @@ async function writeSnapshotData(opts) {
7597
7939
  const yaml = [];
7598
7940
  const pages = [];
7599
7941
  const skippedPages = {};
7942
+ const skippedFiles = {};
7600
7943
  const all = y.tables.map((t) => t.name);
7601
7944
  const unknown = (opts.tables ?? []).filter((n) => !all.includes(n));
7602
7945
  if (unknown.length > 0) throw new Error(`unknown table: ${unknown.join(", ")}`);
@@ -7621,6 +7964,8 @@ async function writeSnapshotData(opts) {
7621
7964
  agent: null,
7622
7965
  git: null,
7623
7966
  reloadConfig: () => {},
7967
+ pageFiles: null,
7968
+ deniedFiles: deniedFiles(root, stateDir, dbPath),
7624
7969
  proposals: new ProposalStore({
7625
7970
  store,
7626
7971
  dry: store,
@@ -7652,6 +7997,7 @@ async function writeSnapshotData(opts) {
7652
7997
  }
7653
7998
  }
7654
7999
  const kept = new Set(pages.map(([name]) => name));
8000
+ copyPageFiles(root, opts.dir, Object.assign({}, ...y.pages.filter((p) => kept.has(p.name)).map((p) => p.files)), ctx.deniedFiles, skippedFiles);
7655
8001
  meta = {
7656
8002
  ...served,
7657
8003
  root: basename(root),
@@ -7722,7 +8068,8 @@ async function writeSnapshotData(opts) {
7722
8068
  tables: selected,
7723
8069
  warnings,
7724
8070
  pages: pages.map(([name]) => name),
7725
- skippedPages
8071
+ skippedPages,
8072
+ skippedFiles
7726
8073
  };
7727
8074
  });
7728
8075
  }
@@ -7810,7 +8157,7 @@ async function exportSite(opts) {
7810
8157
  if (!html.includes("<head>")) throw new Error(`${indexPath} has no <head>`);
7811
8158
  const index = html.replace("<head>", `<head>\n ${STATIC_META}\n ${FILE_GUARD}`);
7812
8159
  writeFileSync(join(staging, "index.html"), index);
7813
- const { tables, warnings, pages, skippedPages } = await writeSnapshotData({
8160
+ const { tables, warnings, pages, skippedPages, skippedFiles } = await writeSnapshotData({
7814
8161
  root: opts.root,
7815
8162
  tables: opts.tables,
7816
8163
  dir: staging,
@@ -7839,7 +8186,8 @@ async function exportSite(opts) {
7839
8186
  tables,
7840
8187
  warnings,
7841
8188
  pages,
7842
- skippedPages
8189
+ skippedPages,
8190
+ skippedFiles
7843
8191
  };
7844
8192
  } catch (e) {
7845
8193
  rmSync(staging, {
@@ -8415,4 +8763,4 @@ async function open(options) {
8415
8763
  };
8416
8764
  }
8417
8765
  //#endregion
8418
- export { ProposalStore as A, CONFIG_NAMES as B, describeChange as C, isRead as D, PageSql as E, createHandler as F, declaredViews as G, parseConfig as H, HttpError as I, Store as K, isLoopback as L, assertSynced as M, toWire as N, statementCount as O, Router as P, loopbackHosts as R, which as S, worthShowing as T, readsFile as U, configPath as V, resolveConfig as W, 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, readRecord as j, EventHub as k, ROUTES as l, STEP_KINDS as m, formatCsv as n, generateConfig as o, localDriver as p, COLUMN_TYPES 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, check 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 };