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.
- package/README.md +21 -15
- package/dist/browser/chokidar.mjs +1 -1
- package/dist/browser/expose.mjs +10 -17
- package/dist/browser/fs.d.mts +5 -0
- package/dist/browser/fs.mjs +1 -1
- package/dist/cli.mjs +3 -2
- package/dist/core.d.mts +31 -14
- package/dist/core.mjs +3 -3
- package/dist/{fs-DZ4QHM93.mjs → fs-BM30qNPb.mjs} +8 -1
- package/dist/{index-OSyELQEX.d.mts → index-Bd2ctbJm.d.mts} +2 -0
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{src-BMrfSYwa.mjs → src-ChJZPkg9.mjs} +376 -28
- package/dist/ui/assets/{ErdView-D3Q6HCAZ.js → ErdView-CWmAQgo6.js} +1 -1
- package/dist/ui/assets/{MarkdownField-Dh4Tn8_S.js → MarkdownField-DI3OMg7I.js} +1 -1
- package/dist/ui/assets/PageView-BsEhcE48.js +1 -0
- package/dist/ui/assets/access-DV6K5419.js +1 -0
- package/dist/ui/assets/index-emolmw-f.js +126 -0
- package/dist/ui/assets/load-BsZuQtRZ.js +2 -0
- package/dist/ui/index.html +1 -1
- package/dist/ui-embed/{ErdView-Dk-iexgy.js → ErdView-CmUxHybj.js} +2 -2
- package/dist/ui-embed/{HintPopover-D5P2UuTa.js → HintPopover-NOWaUpA_.js} +737 -721
- package/dist/ui-embed/PageView-Bptfb1IR.js +1154 -0
- package/dist/ui-embed/{activity-letLdXN4.js → activity-v3qF9H6V.js} +1 -1
- package/dist/ui-embed/embed.js +36 -36
- package/dist/ui-embed/{pageFrame-CFoU6UjD.js → pageFrame-vCls4cAs.js} +1 -1
- package/dist/{workspace-gtZ3m7LH.mjs → workspace-D7xHlBPc.mjs} +78 -6
- package/package.json +1 -1
- package/dist/ui/assets/PageView-9PohkcTy.js +0 -1
- package/dist/ui/assets/index-nxlI4YiX.js +0 -126
- package/dist/ui/assets/load-C3NfhY_i.js +0 -2
- 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
|
-
|
|
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")) >
|
|
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 >
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 };
|