yamlite 0.38.0 → 0.40.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 (30) hide show
  1. package/README.md +7 -3
  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 +22 -13
  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-CDLxve55.d.mts} +2 -0
  11. package/dist/index.d.mts +1 -1
  12. package/dist/index.mjs +1 -1
  13. package/dist/{src-DdRmp-vT.mjs → src-NOJw_gTE.mjs} +185 -12
  14. package/dist/ui/assets/{ErdView-Cv4lBB9J.js → ErdView-Dqs3Ly-E.js} +1 -1
  15. package/dist/ui/assets/{MarkdownField-UKgTcG0X.js → MarkdownField-Bg4zDZM_.js} +1 -1
  16. package/dist/ui/assets/PageView-D0hs43d8.js +1 -0
  17. package/dist/ui/assets/access-DSnHSuXB.js +1 -0
  18. package/dist/ui/assets/{index-BnlDexUD.js → index-LbUt_Zkt.js} +58 -57
  19. package/dist/ui/assets/load-ylhnQAWa.js +2 -0
  20. package/dist/ui/index.html +1 -1
  21. package/dist/ui-embed/{ErdView-Dk-iexgy.js → ErdView-CgNnAf5I.js} +2 -2
  22. package/dist/ui-embed/{HintPopover-D5P2UuTa.js → HintPopover-8CeJLQO5.js} +1611 -1605
  23. package/dist/ui-embed/{PageView-BzCYlXPg.js → PageView-B29tXeqW.js} +158 -141
  24. package/dist/ui-embed/{activity-letLdXN4.js → activity-DecWPHxS.js} +1 -1
  25. package/dist/ui-embed/embed.js +875 -852
  26. package/dist/ui-embed/{pageFrame-CFoU6UjD.js → pageFrame-ClOBfTt9.js} +1 -1
  27. package/dist/{workspace-B4WnIeHR.mjs → workspace-Cp5-yHYp.mjs} +7 -3
  28. package/package.json +1 -1
  29. package/dist/ui/assets/PageView-SFpedZG6.js +0 -1
  30. package/dist/ui/assets/load-S46Mj3YT.js +0 -2
@@ -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,30 @@ 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 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());
1134
+ const CONTENT_TYPES = {
1135
+ png: "image/png",
1136
+ jpg: "image/jpeg",
1137
+ jpeg: "image/jpeg",
1138
+ webp: "image/webp",
1139
+ gif: "image/gif",
1140
+ svg: "image/svg+xml",
1141
+ pdf: "application/pdf",
1142
+ txt: "text/plain"
1143
+ };
1144
+ function contentTypeOf(path) {
1145
+ const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
1146
+ return path.includes(".") && Object.hasOwn(CONTENT_TYPES, ext) ? CONTENT_TYPES[ext] : "application/octet-stream";
1147
+ }
1118
1148
  //#endregion
1119
1149
  //#region src/config.ts
1120
1150
  const CONFIG_NAMES = ["yamlite.yaml", "yamlite.yml"];
@@ -1639,6 +1669,7 @@ const PAGE_KEYS = [
1639
1669
  "access",
1640
1670
  "sql",
1641
1671
  "network",
1672
+ "files",
1642
1673
  "ai"
1643
1674
  ];
1644
1675
  const PAGE_NAME = /^[a-z0-9][a-z0-9_-]*$/;
@@ -1654,6 +1685,16 @@ function toOrigin(where, raw) {
1654
1685
  if (url.protocol !== "https:" && url.protocol !== "http:" || raw.replace(/\/$/, "") !== url.origin) throw invalid();
1655
1686
  return url.origin;
1656
1687
  }
1688
+ 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) => {
1692
+ 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");
1694
+ if (deniedFile(entry, DENIED_FILES)) throw invalid("is YAMLite's own and cannot be granted");
1695
+ return entry;
1696
+ });
1697
+ }
1657
1698
  function toPageAi(where, raw) {
1658
1699
  if (raw === void 0 || raw === null || raw === false) return null;
1659
1700
  if (raw === true) return { maxOutputTokens: PAGE_AI_DEFAULT_TOKENS };
@@ -1694,6 +1735,7 @@ function toPages(raw, place) {
1694
1735
  access,
1695
1736
  sql: v.sql === true,
1696
1737
  network: (v.network ?? []).map((o) => toOrigin(where, o)),
1738
+ files: toFiles(where, v.files),
1697
1739
  ai: toPageAi(where, v.ai)
1698
1740
  };
1699
1741
  });
@@ -4238,6 +4280,37 @@ var ProposalStore = class {
4238
4280
  };
4239
4281
  //#endregion
4240
4282
  //#region src/serve/context.ts
4283
+ function onDisk(path) {
4284
+ try {
4285
+ return realpathSync.native(path);
4286
+ } catch {
4287
+ try {
4288
+ return join(realpathSync.native(dirname(path)), basename(path));
4289
+ } catch {
4290
+ return path;
4291
+ }
4292
+ }
4293
+ }
4294
+ function deniedFiles(root, stateDir, dbPath) {
4295
+ const base = onDisk(root);
4296
+ const inside = (path) => {
4297
+ const rel = relative(base, path);
4298
+ return rel === "" || rel.startsWith("..") || isAbsolute(rel) ? null : rel.split(sep).join("/");
4299
+ };
4300
+ const state = inside(onDisk(stateDir));
4301
+ const given = inside(join(onDisk(dirname(dbPath)), basename(dbPath)));
4302
+ const dbs = [.../* @__PURE__ */ new Set([given, inside(onDisk(dbPath))])].filter((db) => db !== null);
4303
+ return [
4304
+ ...DENIED_FILES,
4305
+ ...state === null ? [] : [`${state}/`],
4306
+ ...dbs.flatMap((db) => [
4307
+ db,
4308
+ `${db}-wal`,
4309
+ `${db}-shm`,
4310
+ `${db}-journal`
4311
+ ])
4312
+ ];
4313
+ }
4241
4314
  function displayPath$1(root, path) {
4242
4315
  const rel = relative(root, path);
4243
4316
  return rel === "" || rel.startsWith("..") ? path : rel;
@@ -6520,11 +6593,56 @@ function pageSpec(ctx, name) {
6520
6593
  if (!page) throw new HttpError(404, `unknown page: ${name}`);
6521
6594
  return page;
6522
6595
  }
6596
+ const FILE_HEADERS = {
6597
+ "cache-control": "no-store",
6598
+ "x-content-type-options": "nosniff",
6599
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox"
6600
+ };
6601
+ const tooLarge = (path) => new HttpError(413, `${path} is larger than 10 MB`);
6602
+ const missing = (path) => new HttpError(404, `file not found: ${path}`);
6603
+ function grantedFile(root, files, denied, path) {
6604
+ let real;
6605
+ let size;
6606
+ try {
6607
+ real = realpathSync.native(join(root, path));
6608
+ const st = statSync(real);
6609
+ if (!st.isFile()) throw missing(path);
6610
+ size = st.size;
6611
+ } catch (e) {
6612
+ if (e instanceof HttpError) throw e;
6613
+ throw missing(path);
6614
+ }
6615
+ const rel = relative(realpathSync.native(root), real);
6616
+ const shown = rel.split(sep).join("/");
6617
+ if (rel.startsWith("..") || isAbsolute(rel) || !fileGranted(files, shown) || deniedFile(shown, denied)) throw new HttpError(403, `${path} leads outside this page's files`);
6618
+ if (size > 10485760) throw tooLarge(path);
6619
+ return {
6620
+ real,
6621
+ size
6622
+ };
6623
+ }
6624
+ const readGranted = (ctx, page, path) => readFileSync(grantedFile(ctx.root, page.files, ctx.deniedFiles, path).real);
6523
6625
  const pageRoutes = (router, ctx) => {
6524
6626
  router.add("GET", "/api/pages/:page/html", ({ params }) => {
6525
6627
  const page = pageSpec(ctx, params.page);
6526
6628
  return { html: readPageHtml(page.path, displayPath$1(ctx.root, page.path)) };
6527
6629
  });
6630
+ router.add("GET", "/api/pages/:page/file", async ({ params, query, signal }) => {
6631
+ const page = pageSpec(ctx, params.page);
6632
+ const asked = query.get("path");
6633
+ if (!asked) throw new HttpError(400, "path is required");
6634
+ const path = normalizeFilePath(asked);
6635
+ if (path === null || !fileGranted(page.files, path)) throw new HttpError(403, `${asked} is not in this page's files`);
6636
+ 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);
6641
+ return new Response(bytes, { headers: {
6642
+ ...FILE_HEADERS,
6643
+ "content-type": contentTypeOf(path)
6644
+ } });
6645
+ });
6528
6646
  };
6529
6647
  //#endregion
6530
6648
  //#region src/serve/routes/pageai.ts
@@ -7077,6 +7195,7 @@ const metaRoutes = (router, ctx) => {
7077
7195
  access: p.access,
7078
7196
  sql: p.sql,
7079
7197
  network: p.network,
7198
+ files: p.files,
7080
7199
  ai: p.ai
7081
7200
  })),
7082
7201
  views: ctx.y.tables.flatMap((t) => declaredViews(t).map(({ spec, parent, depth, path }) => {
@@ -7299,11 +7418,23 @@ const schemaRoutes = (router, ctx) => {
7299
7418
  };
7300
7419
  });
7301
7420
  };
7421
+ const SEPARATORS = [
7422
+ " ",
7423
+ " ",
7424
+ "\n",
7425
+ "\r",
7426
+ "-",
7427
+ "_",
7428
+ ".",
7429
+ "/"
7430
+ ];
7431
+ const isSeparator = (c) => SEPARATORS.includes(c);
7432
+ const squash = (s) => [...s].filter((c) => !isSeparator(c)).join("");
7302
7433
  const DEFAULT_PER = 20;
7303
7434
  const MAX_PER = 200;
7304
7435
  function parseSearchQuery(params) {
7305
7436
  const needle = params.get("q")?.trim() ?? "";
7306
- if ([...needle].length < 2) throw new HttpError(400, `q needs at least 2 characters`);
7437
+ if ([...squash(needle)].length < 2) throw new HttpError(400, `q needs at least 2 characters`);
7307
7438
  const raw = params.get("per");
7308
7439
  if (raw === null) return {
7309
7440
  needle,
@@ -7331,25 +7462,27 @@ function labelColumn(t) {
7331
7462
  }
7332
7463
  const literal = (s) => `'${s.replaceAll("'", "''")}'`;
7333
7464
  const text = (column) => `CAST(${q(column)} AS TEXT)`;
7334
- const contains = (column) => `instr(lower(${text(column)}), needle.v) > 0`;
7465
+ const char = (c) => c < " " ? `char(${c.charCodeAt(0)})` : literal(c);
7466
+ const squashed = (expr) => SEPARATORS.reduce((e, c) => `replace(${e}, ${char(c)}, '')`, `lower(${expr})`);
7467
+ const contains = (column) => `instr(${squashed(text(column))}, needle.v) > 0`;
7335
7468
  function tableHits(t) {
7336
7469
  const columns = [t.key, ...[...t.columns.keys()].filter((c) => c !== t.key)].filter((c) => t.columns.get(c) !== "BOOLEAN");
7337
7470
  if (columns.length === 0) return null;
7338
7471
  const pick = (then) => `CASE ${columns.map((c) => `WHEN ${contains(c)} THEN ${then(c)}`).join(" ")} END`;
7339
7472
  const label = labelColumn(t);
7340
- return `SELECT ${literal(t.name)} AS tbl, ${text(t.key)} AS k, ${label ? text(label) : "NULL"} AS lbl, ${pick(literal)} AS col, ${pick(text)} AS val FROM main.${q(t.name)}, needle WHERE ${columns.map(contains).join(" OR ")}`;
7473
+ return `SELECT ${literal(t.name)} AS tbl, ${text(t.key)} AS k, ${squashed(text(t.key))} AS sk, ${label ? text(label) : "NULL"} AS lbl, ${pick(literal)} AS col, ${pick(text)} AS val FROM main.${q(t.name)}, needle WHERE ${columns.map(contains).join(" OR ")}`;
7341
7474
  }
7342
7475
  function buildSearchSql(tables, needle, per) {
7343
7476
  const selects = tables.map(tableHits).filter((s) => s !== null);
7344
7477
  if (selects.length === 0) return null;
7345
7478
  return {
7346
- sql: `WITH needle(v) AS (SELECT lower(?)),
7479
+ sql: `WITH needle(v) AS (SELECT ${squashed("?")}),
7347
7480
  hits AS (${selects.join(" UNION ALL ")}),
7348
7481
  ranked AS (
7349
7482
  SELECT hits.*,
7350
- CASE WHEN lower(k) = needle.v THEN 0
7351
- WHEN substr(lower(k), 1, length(needle.v)) = needle.v THEN 1
7352
- WHEN instr(lower(k), needle.v) > 0 THEN 2
7483
+ CASE WHEN sk = needle.v THEN 0
7484
+ WHEN substr(sk, 1, length(needle.v)) = needle.v THEN 1
7485
+ WHEN instr(sk, needle.v) > 0 THEN 2
7353
7486
  ELSE 3 END AS grade,
7354
7487
  count(*) OVER (PARTITION BY tbl) AS total
7355
7488
  FROM hits, needle
@@ -7572,6 +7705,40 @@ function warningScrubber(root, specPaths) {
7572
7705
  return name === "" ? out : out.split(root).join(name);
7573
7706
  };
7574
7707
  }
7708
+ function grantedCandidates(root, entries) {
7709
+ const found = [];
7710
+ const walk = (rel) => {
7711
+ let items;
7712
+ try {
7713
+ items = readdirSync(join(root, rel), { withFileTypes: true });
7714
+ } catch {
7715
+ return;
7716
+ }
7717
+ for (const item of items) {
7718
+ const path = `${rel}${item.name}`;
7719
+ if (item.isDirectory()) walk(`${path}/`);
7720
+ else found.push(path);
7721
+ }
7722
+ };
7723
+ for (const entry of entries) if (entry.endsWith("/")) walk(entry);
7724
+ else found.push(entry);
7725
+ return found;
7726
+ }
7727
+ function copyPageFiles(root, dir, grants, denied, skipped) {
7728
+ for (const path of new Set(grantedCandidates(root, grants))) {
7729
+ if (deniedFile(path, denied)) continue;
7730
+ let real;
7731
+ try {
7732
+ real = grantedFile(root, grants, denied, path).real;
7733
+ } catch (e) {
7734
+ if (e instanceof HttpError && e.status === 413) skipped[path] = "is larger than 10 MB";
7735
+ continue;
7736
+ }
7737
+ const dest = join(dir, "data", "files", `${path}.bin`);
7738
+ mkdirSync(dirname(dest), { recursive: true });
7739
+ copyFileSync(real, dest);
7740
+ }
7741
+ }
7575
7742
  const writeJson = (path, value) => writeFileSync(path, JSON.stringify(value));
7576
7743
  async function writeSnapshotData(opts) {
7577
7744
  const root = resolve(opts.root);
@@ -7583,6 +7750,7 @@ async function writeSnapshotData(opts) {
7583
7750
  const yaml = [];
7584
7751
  const pages = [];
7585
7752
  const skippedPages = {};
7753
+ const skippedFiles = {};
7586
7754
  const all = y.tables.map((t) => t.name);
7587
7755
  const unknown = (opts.tables ?? []).filter((n) => !all.includes(n));
7588
7756
  if (unknown.length > 0) throw new Error(`unknown table: ${unknown.join(", ")}`);
@@ -7607,6 +7775,8 @@ async function writeSnapshotData(opts) {
7607
7775
  agent: null,
7608
7776
  git: null,
7609
7777
  reloadConfig: () => {},
7778
+ pageFiles: null,
7779
+ deniedFiles: deniedFiles(root, stateDir, dbPath),
7610
7780
  proposals: new ProposalStore({
7611
7781
  store,
7612
7782
  dry: store,
@@ -7638,6 +7808,7 @@ async function writeSnapshotData(opts) {
7638
7808
  }
7639
7809
  }
7640
7810
  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);
7641
7812
  meta = {
7642
7813
  ...served,
7643
7814
  root: basename(root),
@@ -7708,7 +7879,8 @@ async function writeSnapshotData(opts) {
7708
7879
  tables: selected,
7709
7880
  warnings,
7710
7881
  pages: pages.map(([name]) => name),
7711
- skippedPages
7882
+ skippedPages,
7883
+ skippedFiles
7712
7884
  };
7713
7885
  });
7714
7886
  }
@@ -7796,7 +7968,7 @@ async function exportSite(opts) {
7796
7968
  if (!html.includes("<head>")) throw new Error(`${indexPath} has no <head>`);
7797
7969
  const index = html.replace("<head>", `<head>\n ${STATIC_META}\n ${FILE_GUARD}`);
7798
7970
  writeFileSync(join(staging, "index.html"), index);
7799
- const { tables, warnings, pages, skippedPages } = await writeSnapshotData({
7971
+ const { tables, warnings, pages, skippedPages, skippedFiles } = await writeSnapshotData({
7800
7972
  root: opts.root,
7801
7973
  tables: opts.tables,
7802
7974
  dir: staging,
@@ -7825,7 +7997,8 @@ async function exportSite(opts) {
7825
7997
  tables,
7826
7998
  warnings,
7827
7999
  pages,
7828
- skippedPages
8000
+ skippedPages,
8001
+ skippedFiles
7829
8002
  };
7830
8003
  } catch (e) {
7831
8004
  rmSync(staging, {
@@ -8401,4 +8574,4 @@ async function open(options) {
8401
8574
  };
8402
8575
  }
8403
8576
  //#endregion
8404
- 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 };
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 };