diff --git a/run/input/UPSTREAM_COMMIT b/run/input/UPSTREAM_COMMIT index b29afff..4767355 100644 --- a/run/input/UPSTREAM_COMMIT +++ b/run/input/UPSTREAM_COMMIT @@ -1 +1 @@ -7d1d58fb03e25330c632466412fd3824a7ee9ca8 +ebbf20a37a55025c3ab2a48dee05dbdc0b461ac9 diff --git a/run/input/core/README.md b/run/input/core/README.md index 52aceb8..84261b2 100644 --- a/run/input/core/README.md +++ b/run/input/core/README.md @@ -19,6 +19,62 @@ platform code**. Every platform consumes this one package: > hand-edit it; edit `src/` and re-run `npm run build_core` at the > [repo root](../). +## Using it in a CAP project + +Two `using` lines and an install. The plugin does the rest — identity, the +draft store, app discovery, the bootstrap routes, the UI5 runtime mount and +the service implementation — because none of that is project-specific and +every consumer would otherwise copy the same ~160 lines and keep them in sync +by hand. + +```jsonc +// package.json +{ "dependencies": { "abap2UI5": "..." } } +``` + +```cds +// db/schema.cds +using from 'abap2UI5/z2ui5-model'; // the draft table + +// srv/service.cds +using from 'abap2UI5/z2ui5-service'; // the roundtrip endpoint +``` + +```js +// srv/app/my_app.js — your application +const z2ui5_if_app = require("abap2UI5/z2ui5_if_app"); + +class my_app extends z2ui5_if_app { + name = ""; + main(client) { + if (client.get_event() === "SEND") client.message_toast_display(`Hi ${this.name}`); + client.view_display(/* ...view builder chain... */); + } +} +module.exports = my_app; +``` + +Point the framework at your apps with `Z2UI5_APP_DIRS` (path-separated) or +`require("abap2UI5/register-apps")(__dirname)`, then `cds watch` and open +`/rest/root/z2ui5`. + +The plugin is opt-outable, whole or piecewise, for a project that wants to +wire something itself: + +```jsonc +// package.json +{ "cds": { "z2ui5": { "routes": false } } } // or "activate": false +``` + +`abap2UI5/cap` exports the same `activate(options)` if you prefer to call it +explicitly — which is what the generated cap2UI5 app does, so the app's own +test suite exercises exactly the path an external consumer gets. + +**TypeScript**: `types/index.d.ts` covers the app interface, the client, the +view-builder chain, the engine seam and the CAP entry point. The view builder +is the one that pays for itself — a fluent chain with no completion is a +guessing game. + ## The seam The platform surface is tiny — see [`srv/z2ui5/engine.js`](srv/z2ui5/engine.js): diff --git a/run/input/core/cds-plugin.js b/run/input/core/cds-plugin.js new file mode 100644 index 0000000..ceba25a --- /dev/null +++ b/run/input/core/cds-plugin.js @@ -0,0 +1,40 @@ +/** + * cds-plugin — CAP loads this automatically for any project that depends on + * this package, which is the whole point: `npm i` and a z2ui5 project works, + * with no boilerplate to copy and keep in sync. + * + * What it wires is in srv/cap/activate.js; this file only decides whether to. + * + * Opting out, for a project that wants to wire things itself: + * + * // package.json + * "cds": { "z2ui5": { "activate": false } } + * + * or per concern — `{ "z2ui5": { "routes": false } }` — since a consumer that + * wants one piece done differently should not have to give up the rest. The + * same keys are accepted by activate() directly. + * + * Failure here is deliberately non-fatal. A plugin that throws takes the whole + * CAP server down with it, and this one is loaded into every dependent project + * — including ones that installed the package for its view builder and never + * intended to serve a z2ui5 endpoint at all. + */ +"use strict"; + +try { + const { activate, requireCds } = require("./srv/cap/activate"); + // Not a plain require("@sap/cds"): this package is usually a `file:` + // dependency, npm symlinks those, and Node resolves from the real path — + // i.e. outside the consumer's tree. See requireCds for the full story. + const cds = requireCds(); + const cfg = (cds && cds.env && cds.env.z2ui5) || {}; + + if (cds && cfg.activate !== false) { + const result = activate({ cds, ...cfg }); + if (result.active && process.env.Z2UI5_LOG_PLUGIN !== "0") { + console.log(`[z2ui5] cds-plugin active on ${result.endpoint} (${result.applied.join(", ")})`); + } + } +} catch (e) { + console.error("[z2ui5] cds-plugin failed to activate:", e.message); +} diff --git a/run/input/core/package.json b/run/input/core/package.json index ec820a6..3565d8a 100644 --- a/run/input/core/package.json +++ b/run/input/core/package.json @@ -1,7 +1,7 @@ { "name": "abap2UI5", "version": "1.0.0", - "description": "abap2UI5 core \u2014 the platform-neutral framework package: engine, transpiled classes, webapp and samples. Fully auto-generated (AI + sync pipeline).", + "description": "abap2UI5 core — the platform-neutral framework package: engine, transpiled classes, webapp and samples. Fully auto-generated (AI + sync pipeline).", "repository": "github:cap2UI5/cap2UI5", "license": "MIT", "private": true, @@ -11,6 +11,13 @@ "exports": { ".": "./srv/z2ui5/00/03/z2ui5_cl_util.js", "./engine": "./srv/z2ui5/engine.js", + "./cds-plugin": "./cds-plugin.js", + "./cap": "./srv/cap/activate.js", + "./cap-retention": "./srv/cap/retention.js", + "./z2ui5-model": "./z2ui5-model.cds", + "./z2ui5-model.cds": "./z2ui5-model.cds", + "./z2ui5-service": "./z2ui5-service.cds", + "./z2ui5-service.cds": "./z2ui5-service.cds", "./z2ui5_asset": "./srv/z2ui5/z2ui5_asset.js", "./z2ui5_identity": "./srv/z2ui5/z2ui5_identity.js", "./z2ui5_preferred_param": "./srv/z2ui5/z2ui5_preferred_param.js", @@ -19,6 +26,7 @@ "./cx_root": "./srv/z2ui5/00/00/cx_root.js", "./z2ui5_cl_ui5_util_*": "./srv/z2ui5/00/03/z2ui5_cl_ui5_util_*.js", "./z2ui5_cx_ui5_util_error": "./srv/z2ui5/00/03/z2ui5_cx_ui5_util_error.js", + "./z2ui5_html": "./srv/z2ui5/00/03/z2ui5_html.js", "./z2ui5_cl_ui5_srv_draft": "./srv/z2ui5/01/01/z2ui5_cl_ui5_srv_draft.js", "./z2ui5_cl_ui5_app_cont": "./srv/z2ui5/01/02/z2ui5_cl_ui5_app_cont.js", "./z2ui5_cl_ui5_app_*": "./srv/z2ui5/01/04/z2ui5_cl_ui5_app_*.js", @@ -37,8 +45,6 @@ "./z2ui5_cl_ajson": "./srv/z2ui5/00/01/z2ui5_cl_ajson.js", "./z2ui5_cl_ajson_*": "./srv/z2ui5/00/01/z2ui5_cl_ajson_*.js", "./z2ui5_cx_ajson_error": "./srv/z2ui5/00/01/z2ui5_cx_ajson_error.js", - "./z2ui5_cl_srt_*": "./srv/z2ui5/00/02/z2ui5_cl_srt_*.js", - "./z2ui5_cx_srt": "./srv/z2ui5/00/02/z2ui5_cx_srt.js", "./z2ui5_cl_util": "./srv/z2ui5/00/03/z2ui5_cl_util.js", "./z2ui5_cl_util_api": "./srv/z2ui5/00/03/02/z2ui5_cl_util_api.js", "./z2ui5_cl_util_api_*": "./srv/z2ui5/00/03/02/z2ui5_cl_util_api_*.js", @@ -49,5 +55,6 @@ }, "dependencies": { "openui5-dist": "1.113.0" - } + }, + "types": "./types/index.d.ts" } diff --git a/run/input/core/srv/app/samples/z2ui5_cl_smp_app_000.js b/run/input/core/srv/app/samples/z2ui5_cl_smp_app_000.js index bd03438..e276b1b 100644 --- a/run/input/core/srv/app/samples/z2ui5_cl_smp_app_000.js +++ b/run/input/core/srv/app/samples/z2ui5_cl_smp_app_000.js @@ -346,7 +346,7 @@ class z2ui5_cl_smp_app_000 extends z2ui5_if_app { block_base({ group, header } = {}) { let result = ``; - if (String(group).includes(String(`controls -*`).replace(/\*/g, ""))) { + if ((($v, $p) => { let $r = ""; const $s = String($p); for (let $i = 0; $i < $s.length; $i++) { const $c = $s[$i]; if ($c === "#") { $i++; $r += ($s[$i] || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } else if ($c === "*") { $r += ".*"; } else if ($c === "+") { $r += "."; } else { $r += $c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } } return new RegExp("^" + $r + "$", "i").test(String($v)); })(group, `controls -*`)) { result = header.substr(0, 1).toUpperCase(); } else { result = this.header_base({ header: header }); diff --git a/run/input/core/srv/app/samples/z2ui5_cl_smp_app_197.js b/run/input/core/srv/app/samples/z2ui5_cl_smp_app_197.js index 0f37f4b..28f6f1d 100644 --- a/run/input/core/srv/app/samples/z2ui5_cl_smp_app_197.js +++ b/run/input/core/srv/app/samples/z2ui5_cl_smp_app_197.js @@ -34,7 +34,7 @@ class z2ui5_cl_smp_app_197 extends z2ui5_if_app { } this.mt_table = z2ui5_cl_util.abap_tab_assign(this.mt_table, z2ui5_cl_util.abap_copy(this.mt_table_full)); if (!z2ui5_cl_util.abap_is_initial(t_range)) { - for (let _i = this.mt_table.length - 1; _i >= 0; _i--) { const row = this.mt_table[_i]; if (!((($v, $r) => !$r || !$r.length || $r.some(($x) => ($x.option === `BT` ? $v >= $x.low && $v <= $x.high : $x.option === `NE` ? $v !== $x.low : $x.option === `CP` ? String($v).includes(String($x.low).replace(/\*/g, "")) : $v === $x.low)))(row.product, t_range))) this.mt_table.splice(_i, 1); } + for (let _i = this.mt_table.length - 1; _i >= 0; _i--) { const row = this.mt_table[_i]; if (!((($v, $r) => { if (!$r || !$r.length) return true; let $inc = false, $anyI = false, $exc = false; for (const $x of $r) { const $o = String($x.option || "EQ").toUpperCase(); const $hit = $o === "BT" ? $v >= $x.low && $v <= $x.high : $o === "NB" ? !($v >= $x.low && $v <= $x.high) : $o === "NE" ? $v !== $x.low : $o === "GT" ? $v > $x.low : $o === "GE" ? $v >= $x.low : $o === "LT" ? $v < $x.low : $o === "LE" ? $v <= $x.low : $o === "CP" ? (($v, $p) => { let $r = ""; const $s = String($p); for (let $i = 0; $i < $s.length; $i++) { const $c = $s[$i]; if ($c === "#") { $i++; $r += ($s[$i] || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } else if ($c === "*") { $r += ".*"; } else if ($c === "+") { $r += "."; } else { $r += $c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } } return new RegExp("^" + $r + "$", "i").test(String($v)); })($v, $x.low) : $o === "NP" ? !(($v, $p) => { let $r = ""; const $s = String($p); for (let $i = 0; $i < $s.length; $i++) { const $c = $s[$i]; if ($c === "#") { $i++; $r += ($s[$i] || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } else if ($c === "*") { $r += ".*"; } else if ($c === "+") { $r += "."; } else { $r += $c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } } return new RegExp("^" + $r + "$", "i").test(String($v)); })($v, $x.low) : $v === $x.low; if (String($x.sign || "I").toUpperCase() === "E") { if ($hit) $exc = true; } else { $anyI = true; if ($hit) $inc = true; } } return $exc ? false : ($anyI ? $inc : true); })(row.product, t_range))) this.mt_table.splice(_i, 1); } } } diff --git a/run/input/core/srv/cap/activate.js b/run/input/core/srv/cap/activate.js new file mode 100644 index 0000000..24845fc --- /dev/null +++ b/run/input/core/srv/cap/activate.js @@ -0,0 +1,228 @@ +/** + * activate — the whole CAP wiring for the abap2UI5 core, in one call. + * + * WHY THIS EXISTS + * --------------- + * Everything here used to live in the generated app's `srv/server.js`, which + * meant the only way to get a working cap2UI5 project was to clone a repository + * that is explicitly a build artifact ("do not hand-edit anything outside + * .github/") and inherit ~160 lines of platform boilerplate you then had to + * keep in sync by hand. There was no supported way to *start* a project. + * + * The boilerplate is not project-specific: identity, the draft store, app + * discovery, the bootstrap routes and the UI5 runtime mount are the same in + * every consumer. So they live here, and both entry points call this: + * + * - `cds-plugin.js` at the package root, which CAP loads automatically for + * any project that depends on this package — `npm i` and nothing else; + * - the generated app's own `srv/server.js`, which calls it explicitly. + * + * The second one is what keeps this honest: the app's jest suite exercises + * exactly the code path an external consumer gets. + * + * Everything is opt-outable through `options`, because a consumer that wants + * one piece done differently should not have to give up the rest. + */ +"use strict"; + +/** + * Resolve @sap/cds from the CONSUMER's tree, not ours. + * + * A plain `require("@sap/cds")` is wrong here and fails in the one case that + * matters most. This package is normally a `file:` dependency (the generated + * app vendors it at ./core; a project under development links a checkout), and + * npm installs those as SYMLINKS. Node resolves from the *real* path of the + * requiring file, so the lookup walks up from wherever the package actually + * lives — outside the consumer's tree entirely — and throws. The plugin then + * reports "@sap/cds not resolvable" while running inside a live CAP server, + * which is exactly what the first end-to-end probe of this file hit. + * + * So ask from the consumer's directory explicitly, and keep the plain require + * as the fallback for a normal (non-symlinked) install. + */ +function requireCds() { + for (const paths of [[process.cwd()], null]) { + try { + return paths ? require(require.resolve("@sap/cds", { paths })) : require("@sap/cds"); + } catch { + /* try the next strategy */ + } + } + return null; +} + +/** + * @param {object} [options] + * @param {boolean} [options.identity=true] bind sy-uname to the CDS user + * @param {boolean} [options.store=true] persist drafts in the CDS entity + * @param {boolean} [options.routes=true] GET/HEAD bootstrap routes + * @param {boolean} [options.resources=true] serve the bundled UI5 runtime + * @param {boolean} [options.retention=true] prune expired drafts hourly + * @param {string} [options.path] endpoint path + * @param {string} [options.entity] draft entity, `namespace.name` + * @param {string[]} [options.appDirs] extra folders to scan for apps + */ +function activate(options = {}) { + const cds = options.cds || requireCds(); + if (!cds) return { active: false, reason: "@sap/cds not resolvable" }; + + const { + identity = true, + store = true, + routes = true, + resources = true, + retention = true, + path: endpoint = "/rest/root/z2ui5", + entity = "cap2ui5.z2ui5_t_01", + } = options; + + const engine = require("../z2ui5/engine"); + const applied = []; + + // Identity: who the framework acts for. The core is platform-neutral and has + // no ambient sy-uname, so CAP's request-scoped user is injected as a + // provider — read PER USE, because cds.context is async-local and one + // installed provider has to stay correct under concurrent requests. + if (identity) { + engine.set_identity(() => ({ + user: cds.context?.user?.id, + tenant: cds.context?.tenant, + })); + applied.push("identity"); + } + + // Draft persistence. Every row is stamped with its owner and only ever + // loaded back for that same owner: a draft id is a UUID but not a secret — + // ids travel in request bodies, logs and browser history — so unguessability + // is never the access control. The service projection enforces the same rule + // independently; this path does not go through the service, so it checks for + // itself. + if (store) { + const [namespace] = entity.split("."); + const name = entity.slice(namespace.length + 1); + const owner = () => cds.context?.user?.id || "anonymous"; + const target = () => { + const e = cds.entities(namespace)?.[name]; + if (!e) throw new Error(`z2ui5: draft entity '${entity}' is not in the model`); + return e; + }; + engine.set_store({ + load: async (id) => SELECT.one.from(target()).where({ id, owner: owner() }), + save: async (draft) => { await INSERT.into(target()).entries({ ...draft, owner: owner() }); }, + }); + applied.push("store"); + } + + // App discovery. Apps bundled inside the package are found without + // registration; anything outside it has to be pointed at. Z2UI5_APP_DIRS is + // the deployment-time knob (path-separated), `appDirs` the code-level one. + const dirs = [ + ...(options.appDirs || []), + ...String(process.env.Z2UI5_APP_DIRS || "").split(require("path").delimiter).filter(Boolean), + ]; + for (const dir of dirs) engine.register_app_dir(dir); + if (dirs.length) applied.push(`appDirs(${dirs.length})`); + + if (retention) { + cds.on("served", () => { + try { + require("./retention").start({ cds, entity }); + } catch (e) { + console.error("[z2ui5] draft retention could not start:", e.message); + } + }); + applied.push("retention"); + } + + if (routes || resources) { + cds.on("bootstrap", (app) => { + if (resources) mountResources(app, engine); + if (routes) mountRoutes(app, engine, endpoint, cds); + }); + applied.push(...[resources && "resources", routes && "routes"].filter(Boolean)); + } + + // Implement the action of whichever service carries it. Registered by + // reacting to the service being served rather than by shipping a `.js` next + // to the `.cds`, so it also binds when a consumer renames the service or + // declares the action on their own. + if (options.impl !== false) { + cds.on("serving", (srv) => { + if (!srv.definition?.actions?.z2ui5) return; + srv.on("z2ui5", require("../z2ui5/02/z2ui5_cl_ui5_http_handler")); + }); + applied.push("impl"); + } + + return { active: true, applied, endpoint, entity }; +} + +/** + * Serve the bundled UI5 runtime at /resources. Registered before the CDS + * services so the OData/REST routing cannot shadow it. The trailing handler + * answers a plain 404 for files the dist does not ship (locale bundles UI5 + * probes for and then falls back on) instead of letting each miss bubble up as + * a logged error. + */ +function mountResources(app, engine) { + const dir = engine.ui5_resources_dir?.(); + if (!dir) { + // Not fatal: a consumer may deliberately bootstrap UI5 from a CDN. Say so + // once, clearly, rather than failing at the first blank page. + console.warn("[z2ui5] openui5-dist not resolvable — /resources not served; bootstrap from a CDN instead"); + return; + } + const express = require("express"); + app.use("/resources", express.static(dir), (_req, res) => res.status(404).end()); +} + +function mountRoutes(app, engine, endpoint, cds) { + const z2ui5_cl_util_http = require("../z2ui5/00/03/z2ui5_cl_util_http"); + + // GET — the bootstrap shell. Public on purpose: it carries no user data and + // keeping it open preserves the offline/dev flow. In BTP the approuter + // authenticates before the frontend can reach it anyway. + app.get(endpoint, (req, res) => { + // bootstrap_html renders arbitrary app HTML — never let a failure escape + // as an unhandled express error, i.e. a raw stack trace to the client. + try { + const reqInfo = z2ui5_cl_util_http.factory_cloud(req, res).get_req_info(); + const { html, headers } = engine.bootstrap_html(reqInfo); + for (const h of headers) res.set(h.n, h.v); + res.set("Content-Type", "text/html; charset=utf-8"); + res.status(200).send(html); + } catch (e) { + console.error(`[z2ui5] GET ${endpoint} bootstrap failed:`, e); + res.status(500).set("Content-Type", "text/plain; charset=utf-8").send(`z2ui5 bootstrap failed: ${e.message}`); + } + }); + + // HEAD serves two clients: the CSRF prefetch, and the beacon the webapp + // sends on tab close (`sap-terminate: session`). That beacon is the only + // signal a session is over, so it is where a sticky app's retained state is + // released — otherwise it lingers until the store evicts it under pressure. + app.head(endpoint, (req, res) => { + if (String(req.get("sap-terminate") || "").toLowerCase() === "session") { + // Registered on the bootstrap express app, ahead of the CDS middleware + // chain, so cds.context is not established here and the key has to come + // from whatever the request itself carries. When that is nobody we drop + // NOTHING: releasing a guessed key would evict a stranger's session. + const user = req.user?.id || cds.context?.user?.id; + const session_id = engine.session_key_for({ user, tenant: cds.context?.tenant }); + if (session_id) { + try { + engine.drop_sticky({ session_id }); + } catch (e) { + console.error("[z2ui5] sticky release failed:", e.message); + } + } + } + // The endpoint uses no token-based CSRF — it validates Origin/Referer + // instead (z2ui5_cl_ui5_http_handler._check_csrf_rejected) — so "disabled" + // is the accurate answer to a token prefetch, not an absence of protection. + res.set("X-CSRF-Token", "disabled"); + res.status(200).end(); + }); +} + +module.exports = { activate, requireCds }; diff --git a/run/input/core/srv/cap/retention.js b/run/input/core/srv/cap/retention.js new file mode 100644 index 0000000..bcb4fb2 --- /dev/null +++ b/run/input/core/srv/cap/retention.js @@ -0,0 +1,89 @@ +/** + * retention — prune expired draft rows. + * + * The draft table is append-only (one row per roundtrip, chained via id_prev + * for back-navigation), so without this it grows without bound: rows past the + * TTL are dead weight whose ids are long gone from any live browser session. + * + * Z2UI5_DRAFT_TTL_HOURS TTL in hours; 0 disables. Defaults to the + * framework's own draft_exp_time_in_hours. + * Z2UI5_DRAFT_RETENTION_INSTANCE which CF instance runs the loop + * (default "0"; "*" means all of them). + * + * THE TTL IS ONE SETTING, NOT TWO. The framework carries its own expiry on the + * http-post exit config, and for a long time this job ignored it: it deleted at + * 24h while the framework believed 4h, and nothing said which was true. The + * framework value is now the source of truth and the env var overrides both. + * + * ONE INSTANCE DELETES, NOT ALL OF THEM. Every instance used to run the loop, + * so N instances meant N concurrent hourly DELETEs over the same rows — + * the same work N times, contending. Retention is housekeeping, not + * per-instance state. + */ +"use strict"; + +const DEFAULT_TTL_HOURS = 4; + +/** The framework's own expiry. Never throws: retention must not break boot. */ +function frameworkTtlHours() { + try { + const exit = require("../z2ui5/01/04/z2ui5_cl_ui5_user_exit"); + const cfg = exit.get_instance().set_config_http_post({ cs_config: {} }); + const n = Number(cfg?.draft_exp_time_in_hours); + if (Number.isFinite(n) && n > 0) return n; + } catch { + /* not resolvable here — fall through */ + } + return DEFAULT_TTL_HOURS; +} + +function ttlHours() { + const raw = process.env.Z2UI5_DRAFT_TTL_HOURS; + if (raw === undefined || raw === "") return frameworkTtlHours(); + const n = Number(raw); + return Number.isFinite(n) && n >= 0 ? n : frameworkTtlHours(); +} + +/** Whether THIS instance runs the loop. Outside CF, a single process always does. */ +function isRetentionInstance() { + const want = process.env.Z2UI5_DRAFT_RETENTION_INSTANCE ?? "0"; + if (want === "*") return true; + const idx = process.env.CF_INSTANCE_INDEX; + if (idx === undefined || idx === "") return true; + return String(idx) === String(want); +} + +async function deleteExpiredDrafts({ cds, entity = "cap2ui5.z2ui5_t_01", now = Date.now() } = {}) { + const ttl = ttlHours(); + if (ttl === 0) return 0; + // Resolve CAP ourselves when the caller did not hand it over: a test or a + // one-off script should be able to call this without knowing that the + // package cannot use a plain require for @sap/cds (see cap/activate.js). + cds = cds || require("./activate").requireCds(); + if (!cds) return 0; + const [namespace] = entity.split("."); + const name = entity.slice(namespace.length + 1); + const target = cds.entities(namespace)?.[name]; + if (!target) return 0; + const cutoff = new Date(now - ttl * 3600 * 1000).toISOString(); + const deleted = await DELETE.from(target).where({ createdAt: { "<": cutoff } }); + if (deleted) console.log(`[z2ui5] draft retention: deleted ${deleted} row(s) older than ${ttl}h`); + return deleted; +} + +function start({ cds, entity } = {}) { + const ttl = ttlHours(); + if (ttl === 0) return; + if (!isRetentionInstance()) { + console.log(`[z2ui5] draft retention: instance ${process.env.CF_INSTANCE_INDEX} is not the retention instance — skipping`); + return; + } + console.log(`[z2ui5] draft retention: deleting drafts older than ${ttl}h, hourly`); + const run = () => + deleteExpiredDrafts({ cds, entity }).catch((e) => console.error("[z2ui5] draft retention failed:", e.message)); + run(); + // unref'd: housekeeping must never be the reason a process stays alive. + setInterval(run, 3600 * 1000).unref(); +} + +module.exports = { start, deleteExpiredDrafts, ttlHours, isRetentionInstance }; diff --git a/run/input/core/srv/z2ui5/00/00/cl_abap_objectdescr.js b/run/input/core/srv/z2ui5/00/00/cl_abap_objectdescr.js index 0f0ce15..e98e771 100644 --- a/run/input/core/srv/z2ui5/00/00/cl_abap_objectdescr.js +++ b/run/input/core/srv/z2ui5/00/00/cl_abap_objectdescr.js @@ -1,6 +1,5 @@ /** cl_abap_objectdescr — native shim (describe_by_object_ref + visibility consts). */ "use strict"; -const rtti = require("./abap_rtti"); const cl_abap_typedescr = require("./cl_abap_typedescr"); class cl_abap_objectdescr extends cl_abap_typedescr { diff --git a/run/input/core/srv/z2ui5/00/01/z2ui5_cl_ajson_mapping.js b/run/input/core/srv/z2ui5/00/01/z2ui5_cl_ajson_mapping.js index b2d712e..532ff12 100644 --- a/run/input/core/srv/z2ui5/00/01/z2ui5_cl_ajson_mapping.js +++ b/run/input/core/srv/z2ui5/00/01/z2ui5_cl_ajson_mapping.js @@ -35,12 +35,12 @@ class lcl_mapping_fields { } } - to_abap({ iv_path, iv_name } = {}) { + to_abap({ iv_path: _iv_path, iv_name } = {}) { const hit = this.mt_mapping_fields.find((r) => r.json === iv_name); return hit ? hit.abap : ""; } - to_json({ iv_path, iv_name } = {}) { + to_json({ iv_path: _iv_path, iv_name } = {}) { const field = String(iv_name ?? "").toUpperCase(); const hit = this.mt_mapping_fields.find((r) => r.abap === field); return hit ? hit.json : ""; diff --git a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_aunit.js b/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_aunit.js deleted file mode 100644 index 71035ed..0000000 --- a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_aunit.js +++ /dev/null @@ -1,33 +0,0 @@ -// TODO(abap2js): unresolved reference cl_abap_typedescr — add require manually -// TODO(abap2js): unresolved reference cl_abap_unit_assert — add require manually -const z2ui5_cl_srt_typedescr = require("abap2UI5/z2ui5_cl_srt_typedescr"); -const cl_abap_typedescr = require("abap2UI5/cl_abap_typedescr"); -const cl_abap_unit_assert = require("abap2UI5/cl_abap_unit_assert"); - -class z2ui5_cl_srt_aunit { - static serialize_deserialize({ variable } = {}) { - // TODO(abap2js): FIELD-SYMBOLS TYPE any. - let rtti1 = null; - let srtti1 = null; - let xstring = null; - let srtti2 = null; - let temp1 = null; - let rtti2 = null; - let ref_variable2 = null; - // TODO(abap2js): FIELD-SYMBOLS TYPE any. - // TODO(abap2js): ASSIGN variable TO . - rtti1 = cl_abap_typedescr.describe_by_data(variable1); - srtti1 = z2ui5_cl_srt_typedescr.create_by_data_object(variable1); - // TODO(abap2js): CALL TRANSFORMATION id SOURCE srtti = srtti1 dobj = RESULT XML xstring OPTIONS data_refs = 'heap-or-create'. - // TODO(abap2js): CALL TRANSFORMATION id SOURCE XML xstring RESULT srtti = srtti2. - temp1 = srtti2.get_rtti(); - rtti2 = temp1; - // TODO(abap2js): CREATE DATA ref_variable2 TYPE HANDLE rtti2. - // TODO(abap2js): ASSIGN ref_variable2->* TO . - // TODO(abap2js): CALL TRANSFORMATION id SOURCE XML xstring RESULT dobj = . - cl_abap_unit_assert.assert_equals({ exp: rtti1, act: rtti2 }); - cl_abap_unit_assert.assert_equals({ exp: variable1, act: variable2 }); - } -} - -module.exports = z2ui5_cl_srt_aunit; diff --git a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_classdescr.js b/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_classdescr.js deleted file mode 100644 index c38ab6c..0000000 --- a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_classdescr.js +++ /dev/null @@ -1,14 +0,0 @@ -const z2ui5_cl_srt_objectdescr = require("abap2UI5/z2ui5_cl_srt_objectdescr"); - -class z2ui5_cl_srt_classdescr extends z2ui5_cl_srt_objectdescr { - class_kind = null; - create_visibility = null; - - constructor({ rtti } = {}) { - super.constructor(rtti); - this.class_kind = rtti.class_kind; - this.create_visibility = rtti.create_visibility; - } -} - -module.exports = z2ui5_cl_srt_classdescr; diff --git a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_complexdescr.js b/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_complexdescr.js deleted file mode 100644 index 3a90336..0000000 --- a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_complexdescr.js +++ /dev/null @@ -1,7 +0,0 @@ -const z2ui5_cl_srt_datadescr = require("abap2UI5/z2ui5_cl_srt_datadescr"); - -class z2ui5_cl_srt_complexdescr extends z2ui5_cl_srt_datadescr { -} - -module.exports = z2ui5_cl_srt_complexdescr; - diff --git a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_datadescr.js b/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_datadescr.js deleted file mode 100644 index 9cb7764..0000000 --- a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_datadescr.js +++ /dev/null @@ -1,7 +0,0 @@ -const z2ui5_cl_srt_typedescr = require("abap2UI5/z2ui5_cl_srt_typedescr"); - -class z2ui5_cl_srt_datadescr extends z2ui5_cl_srt_typedescr { -} - -module.exports = z2ui5_cl_srt_datadescr; - diff --git a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_elemdescr.js b/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_elemdescr.js deleted file mode 100644 index a1b42f7..0000000 --- a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_elemdescr.js +++ /dev/null @@ -1,84 +0,0 @@ -// TODO(abap2js): unresolved reference cl_abap_char_utilities — add require manually -// TODO(abap2js): unresolved reference cl_abap_elemdescr — add require manually -// TODO(abap2js): unresolved reference cl_abap_typedescr — add require manually -const z2ui5_cl_srt_datadescr = require("abap2UI5/z2ui5_cl_srt_datadescr"); -const z2ui5_cx_srt = require("abap2UI5/z2ui5_cx_srt"); -const cl_abap_char_utilities = require("abap2UI5/cl_abap_char_utilities"); -const cl_abap_elemdescr = require("abap2UI5/cl_abap_elemdescr"); -const cl_abap_typedescr = require("abap2UI5/cl_abap_typedescr"); - -class z2ui5_cl_srt_elemdescr extends z2ui5_cl_srt_datadescr { - edit_mask = null; - help_id = null; - output_length = null; - - constructor({ rtti } = {}) { - super.constructor(rtti); - this.edit_mask = rtti.edit_mask; - this.help_id = rtti.help_id; - this.output_length = rtti.output_length; - } - - get_rtti() { - rtti = super.get_rtti(); - if (rtti != null) { - return; - } - if (is_ddic_type === true && technical_type === false) { - rtti = cl_abap_typedescr.describe_by_name(absolute_name); - } else { - rtti = this.get_rtti_by_type_kind({ i_type_kind: type_kind }); - } - } - - get_rtti_by_type_kind({ i_type_kind } = {}) { - let rtti = null; - let l_length = 0; - switch (i_type_kind) { - case cl_abap_typedescr.typekind_num: - l_length = length / cl_abap_char_utilities.charsize; - rtti = cl_abap_elemdescr.get_n(l_length); - break; - case cl_abap_typedescr.typekind_char: - l_length = length / cl_abap_char_utilities.charsize; - rtti = cl_abap_elemdescr.get_c(l_length); - break; - case cl_abap_typedescr.typekind_string: - rtti = cl_abap_elemdescr.get_string(); - break; - case cl_abap_typedescr.typekind_xstring: - rtti = cl_abap_elemdescr.get_xstring(); - break; - case cl_abap_typedescr.typekind_int: - rtti = cl_abap_elemdescr.get_i(); - break; - case cl_abap_typedescr.typekind_float: - rtti = cl_abap_elemdescr.get_f(); - break; - case cl_abap_typedescr.typekind_date: - rtti = cl_abap_elemdescr.get_d(); - break; - case cl_abap_typedescr.typekind_time: - rtti = cl_abap_elemdescr.get_t(); - break; - case cl_abap_typedescr.typekind_hex: - rtti = cl_abap_elemdescr.get_x(length); - break; - case cl_abap_typedescr.typekind_packed: - rtti = cl_abap_elemdescr.get_p({ p_length: length, p_decimals: decimals }); - break; - case cl_abap_typedescr.typekind_decfloat16: - rtti = cl_abap_elemdescr.get_decfloat16(); - break; - case cl_abap_typedescr.typekind_decfloat34: - rtti = cl_abap_elemdescr.get_decfloat34(); - break; - default: - throw new z2ui5_cx_srt(); - break; - } - return rtti; - } -} - -module.exports = z2ui5_cl_srt_elemdescr; diff --git a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_intfdescr.js b/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_intfdescr.js deleted file mode 100644 index ea8cb72..0000000 --- a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_intfdescr.js +++ /dev/null @@ -1,12 +0,0 @@ -const z2ui5_cl_srt_objectdescr = require("abap2UI5/z2ui5_cl_srt_objectdescr"); - -class z2ui5_cl_srt_intfdescr extends z2ui5_cl_srt_objectdescr { - intf_kind = null; - - constructor({ rtti } = {}) { - super.constructor(rtti); - this.intf_kind = rtti.intf_kind; - } -} - -module.exports = z2ui5_cl_srt_intfdescr; diff --git a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_objectdescr.js b/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_objectdescr.js deleted file mode 100644 index 2e74afc..0000000 --- a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_objectdescr.js +++ /dev/null @@ -1,24 +0,0 @@ -const z2ui5_cl_srt_typedescr = require("abap2UI5/z2ui5_cl_srt_typedescr"); - -class z2ui5_cl_srt_objectdescr extends z2ui5_cl_srt_typedescr { - interfaces = null; - types = null; - attributes = null; - methods = null; - events = null; - - constructor({ rtti } = {}) { - super.constructor(rtti); - this.interfaces = rtti.interfaces; - this.types = rtti.types; - this.attributes = rtti.attributes; - this.methods = rtti.methods; - this.events = rtti.events; - // TODO(abap2js): READ TABLE interfaces WITH KEY name = 'IF_SERIALIZABLE_OBJECT' TRANSPORTING NO FIELDS. - if (sy_subrc !== 0) { - not_serializable = true; - } - } -} - -module.exports = z2ui5_cl_srt_objectdescr; diff --git a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_refdescr.js b/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_refdescr.js deleted file mode 100644 index 9b4802d..0000000 --- a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_refdescr.js +++ /dev/null @@ -1,30 +0,0 @@ -// TODO(abap2js): unresolved reference cl_abap_refdescr — add require manually -// TODO(abap2js): unresolved reference cl_abap_typedescr — add require manually -const z2ui5_cl_srt_datadescr = require("abap2UI5/z2ui5_cl_srt_datadescr"); -const cl_abap_refdescr = require("abap2UI5/cl_abap_refdescr"); -const cl_abap_typedescr = require("abap2UI5/cl_abap_typedescr"); - -class z2ui5_cl_srt_refdescr extends z2ui5_cl_srt_datadescr { - referenced_type = null; - - constructor({ rtti } = {}) { - super.constructor(rtti); - this.referenced_type = null; // TODO(abap2js): CREATE OBJECT referenced_type TYPE z2ui5_cl_srt_typedescr EXPORTING rtti = rtti->get_referenced_type( ). - if (this.referenced_type.not_serializable === true) { - not_serializable = true; - } - } - - get_rtti() { - if (this.referenced_type.type_kind === cl_abap_typedescr.typekind_data) { - rtti = cl_abap_refdescr.get_ref_to_data(); - } else if (this.referenced_type.absolute_name === `\\CLASS=OBJECT`) { - rtti = cl_abap_refdescr.get_ref_to_object(); - } else { - rtti = this.referenced_type.get_rtti(); - } - rtti = cl_abap_refdescr.create(rtti); - } -} - -module.exports = z2ui5_cl_srt_refdescr; diff --git a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_structdescr.js b/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_structdescr.js deleted file mode 100644 index 09178f9..0000000 --- a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_structdescr.js +++ /dev/null @@ -1,62 +0,0 @@ -// TODO(abap2js): unresolved reference cl_abap_structdescr — add require manually -const z2ui5_cl_srt_complexdescr = require("abap2UI5/z2ui5_cl_srt_complexdescr"); -const z2ui5_cl_srt_datadescr = require("abap2UI5/z2ui5_cl_srt_datadescr"); -const cl_abap_structdescr = require("abap2UI5/cl_abap_structdescr"); - -class z2ui5_cl_srt_structdescr extends z2ui5_cl_srt_complexdescr { - struct_kind = null; - components = null; - has_include = null; - - constructor({ rtti } = {}) { - let sy_tabix = 0; - let components_rtti = []; - let scomponent = null; - let scomponent_rtti = null; - // TODO(abap2js): FIELD-SYMBOLS TYPE abap_componentdescr. - super.constructor(rtti); - this.struct_kind = rtti.struct_kind; - this.has_include = rtti.has_include; - components_rtti = rtti.get_components(); - sy_tabix = 0; - for (const component of components_rtti) { - sy_tabix++; - scomponent = null; - scomponent.name = component.name; - scomponent_rtti = z2ui5_cl_srt_datadescr.create_by_rtti(component.type); - scomponent.type = scomponent_rtti; - scomponent.as_include = component.as_include; - scomponent.suffix = component.suffix; - this.components.push(scomponent); - if (scomponent.type.not_serializable === true) { - not_serializable = true; - } - } - } - - get_rtti() { - let sy_tabix = 0; - let components_rtti = []; - let component_rtti = null; - // TODO(abap2js): FIELD-SYMBOLS TYPE sabap_componentdescr. - components_rtti = null; - sy_tabix = 0; - for (const component of this.components) { - sy_tabix++; - component_rtti = null; - component_rtti.name = component.name; - try { - component_rtti.type = component.type.get_rtti(); - } catch (x) { - const lv_method = `GET_BY_KIND`; - // TODO(abap2js): CALL METHOD cl_abap_elemdescr=>(lv_method) EXPORTING p_type_kind = -type->type_kind p_length = -type->length p_decimals = -type->decimals RECEIVING p_result = component_rtti-type. - } - component_rtti.as_include = component.as_include; - component_rtti.suffix = component.suffix; - components_rtti.push(component_rtti); - } - rtti = cl_abap_structdescr.create(components_rtti); - } -} - -module.exports = z2ui5_cl_srt_structdescr; diff --git a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_tabledescr.js b/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_tabledescr.js deleted file mode 100644 index 6ab5d06..0000000 --- a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_tabledescr.js +++ /dev/null @@ -1,51 +0,0 @@ -// TODO(abap2js): unresolved reference cl_abap_tabledescr — add require manually -const z2ui5_cl_srt_complexdescr = require("abap2UI5/z2ui5_cl_srt_complexdescr"); -const z2ui5_cl_srt_typedescr = require("abap2UI5/z2ui5_cl_srt_typedescr"); -const z2ui5_cx_srt = require("abap2UI5/z2ui5_cx_srt"); -const cl_abap_tabledescr = require("abap2UI5/cl_abap_tabledescr"); - -class z2ui5_cl_srt_tabledescr extends z2ui5_cl_srt_complexdescr { - key = null; - initial_size = null; - key_defkind = null; - has_unique_key = null; - table_kind = null; - line_type = null; - - constructor({ rtti } = {}) { - super.constructor(rtti); - this.key = rtti.key; - this.initial_size = rtti.initial_size; - this.key_defkind = rtti.key_defkind; - this.has_unique_key = rtti.has_unique_key; - this.table_kind = rtti.table_kind; - this.line_type = z2ui5_cl_srt_typedescr.create_by_rtti(rtti.get_table_line_type()); - if (this.line_type.not_serializable === true) { - not_serializable = true; - } - } - - get_rtti() { - let lt_empty_key = []; - let lo_data_rtti = null; - let lo_error = null; - // TODO(abap2js): FIELD-SYMBOLS TYPE abap_keydescr_tab. - lt_empty_key = null; - switch (this.key_defkind) { - case cl_abap_tabledescr.keydefkind_user: - // TODO(abap2js): ASSIGN key TO . - break; - default: - // TODO(abap2js): ASSIGN lt_empty_key TO . - break; - } - try { - lo_data_rtti = this.line_type.get_rtti(); - rtti = cl_abap_tabledescr.create({ p_line_type: lo_data_rtti, p_table_kind: this.table_kind, p_unique: this.has_unique_key, p_key: lt_key, p_key_kind: this.key_defkind }); - } catch (lo_error) { - throw new z2ui5_cx_srt({ previous: lo_error }); - } - } -} - -module.exports = z2ui5_cl_srt_tabledescr; diff --git a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_typedescr.js b/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_typedescr.js deleted file mode 100644 index c1cf297..0000000 --- a/run/input/core/srv/z2ui5/00/02/z2ui5_cl_srt_typedescr.js +++ /dev/null @@ -1,82 +0,0 @@ -// TODO(abap2js): unresolved reference cl_abap_typedescr — add require manually -const z2ui5_cx_srt = require("abap2UI5/z2ui5_cx_srt"); -const cl_abap_typedescr = require("abap2UI5/cl_abap_typedescr"); - -class z2ui5_cl_srt_typedescr { - absolute_name = null; - type_kind = null; - length = null; - decimals = null; - kind = null; - not_serializable = false; - is_ddic_type = false; - technical_type = false; - - constructor({ rtti } = {}) { - this.absolute_name = rtti.absolute_name; - this.type_kind = rtti.type_kind; - this.length = rtti.length; - this.decimals = rtti.decimals; - this.kind = rtti.kind; - this.is_ddic_type = rtti.is_ddic_type(); - if (String(rtti.absolute_name).includes(String(`\\TYPE=%_T*`).replace(/\*/g, ""))) { - this.technical_type = true; - } - } - - static create_by_data_object({ data_object } = {}) { - let srtti = null; - srtti = z2ui5_cl_srt_typedescr.create_by_rtti({ rtti: cl_abap_typedescr.describe_by_data(data_object) }); - return srtti; - } - - static create_by_rtti({ rtti } = {}) { - let srtti = null; - let elem_rtti = null; - let struct_rtti = null; - let table_rtti = null; - let ref_rtti = null; - let class_rtti = null; - let intf_rtti = null; - switch (rtti.kind) { - case cl_abap_typedescr.kind_elem: - elem_rtti = rtti; - srtti = null; // TODO(abap2js): CREATE OBJECT srtti TYPE z2ui5_cl_srt_elemdescr EXPORTING rtti = elem_rtti. - break; - case cl_abap_typedescr.kind_struct: - struct_rtti = rtti; - srtti = null; // TODO(abap2js): CREATE OBJECT srtti TYPE z2ui5_cl_srt_structdescr EXPORTING rtti = struct_rtti. - break; - case cl_abap_typedescr.kind_table: - table_rtti = rtti; - srtti = null; // TODO(abap2js): CREATE OBJECT srtti TYPE z2ui5_cl_srt_tabledescr EXPORTING rtti = table_rtti. - break; - case cl_abap_typedescr.kind_ref: - ref_rtti = rtti; - srtti = null; // TODO(abap2js): CREATE OBJECT srtti TYPE z2ui5_cl_srt_refdescr EXPORTING rtti = ref_rtti. - break; - case cl_abap_typedescr.kind_class: - class_rtti = rtti; - srtti = null; // TODO(abap2js): CREATE OBJECT srtti TYPE z2ui5_cl_srt_classdescr EXPORTING rtti = class_rtti. - break; - case cl_abap_typedescr.kind_intf: - intf_rtti = rtti; - srtti = null; // TODO(abap2js): CREATE OBJECT srtti TYPE z2ui5_cl_srt_intfdescr EXPORTING rtti = intf_rtti. - break; - default: - throw new z2ui5_cx_srt(); - break; - } - return srtti; - } - - get_rtti() { - let rtti = null; - if (this.technical_type === false) { - rtti = cl_abap_typedescr.describe_by_name(this.absolute_name); - } - return rtti; - } -} - -module.exports = z2ui5_cl_srt_typedescr; diff --git a/run/input/core/srv/z2ui5/00/02/z2ui5_cx_srt.js b/run/input/core/srv/z2ui5/00/02/z2ui5_cx_srt.js deleted file mode 100644 index 35aef21..0000000 --- a/run/input/core/srv/z2ui5/00/02/z2ui5_cx_srt.js +++ /dev/null @@ -1,15 +0,0 @@ -const cx_no_check = class {}; // TODO(abap2js): unresolved superclass — replace stub manually - -class z2ui5_cx_srt extends cx_no_check { - constructor({ textid, previous } = {}) { - // TODO(abap2js): CALL METHOD super->constructor EXPORTING previous = previous. - this.textid = null; - if (!textid) { - this.t100key = if_t100_message.default_textid; - } else { - this.t100key = textid; - } - } -} - -module.exports = z2ui5_cx_srt; diff --git a/run/input/core/srv/z2ui5/00/03/z2ui5_cl_util_json_fltr.js b/run/input/core/srv/z2ui5/00/03/z2ui5_cl_util_json_fltr.js index c1eea44..cac64ea 100644 --- a/run/input/core/srv/z2ui5/00/03/z2ui5_cl_util_json_fltr.js +++ b/run/input/core/srv/z2ui5/00/03/z2ui5_cl_util_json_fltr.js @@ -1,32 +1,65 @@ const z2ui5_if_ajson_filter = require("abap2UI5/z2ui5_if_ajson_filter"); const z2ui5_if_ajson_types = require("abap2UI5/z2ui5_if_ajson_types"); +/** + * z2ui5_cl_util_json_fltr — an ajson filter that drops empty values. + * + * Shipped as `abap2UI5/z2ui5_cl_util_json_fltr`, so it is a public API. + * + * It did not work. The file was raw transpiler output that nobody finished: + * `keep_node()` declared no parameters while using the ABAP importing names + * `iv_visit` and `is_node`, assigned to an undeclared `rv_keep`, and returned + * nothing at all. Every call was a ReferenceError in strict mode, and on the + * happy path it would have answered `undefined` — which ajson reads as "drop + * this node", i.e. it would have filtered away the whole document. + * + * Nothing called it: `z2ui5_cl_ajson_filter_lib` carries a working + * equivalent (`lcl_empty_filter`) and `z2ui5_cl_ui5_handler` a second one, + * documented there as a "mirror of" this class. Rather than delete a public + * name, it now does what it always claimed to. + * + * The contract is the one z2ui5_cl_ajson calls with + * (`z2ui5_cl_ajson.js` → `keep_node({ is_node, iv_visit })`): named arguments, + * returning a boolean. + */ class z2ui5_cl_util_json_fltr { static create_no_empty_values() { - let result = null; - result = new z2ui5_cl_util_json_fltr(); - return result; + return new z2ui5_cl_util_json_fltr(); } - keep_node() { - rv_keep = true; + /** + * @param {object} is_node the ajson node being visited + * @param {string} iv_visit value | open | close + * @returns {boolean} true to keep the node + */ + keep_node({ is_node, iv_visit = z2ui5_if_ajson_filter.visit_type.value } = {}) { + if (!is_node) return false; + switch (iv_visit) { case z2ui5_if_ajson_filter.visit_type.value: switch (is_node.type) { case z2ui5_if_ajson_types.node_type.boolean: - rv_keep = Boolean(is_node.value !== `false`); - break; + return String(is_node.value) !== `false`; case z2ui5_if_ajson_types.node_type.number: - rv_keep = Boolean(is_node.value !== `0`); - break; + return String(is_node.value) !== `0`; case z2ui5_if_ajson_types.node_type.string: - rv_keep = Boolean(is_node.value !== ``); - break; + return String(is_node.value) !== ``; + default: + // null and anything unrecognised: keep, so an unknown node type is + // never silently dropped from a document. + return true; } - break; + case z2ui5_if_ajson_filter.visit_type.close: - rv_keep = Boolean(is_node.children !== 0); - break; + // Arrays and objects survive only if something inside them did. The + // count is 0 both for a node that was empty to begin with and for one + // whose children this filter has just removed. + return Number(is_node.children) > 0; + + default: + // `open` — the children have not been visited yet, so there is nothing + // to decide on. Deciding here would drop every container. + return true; } } } diff --git a/run/input/core/srv/z2ui5/00/03/z2ui5_html.js b/run/input/core/srv/z2ui5/00/03/z2ui5_html.js new file mode 100644 index 0000000..b4f32f6 --- /dev/null +++ b/run/input/core/srv/z2ui5/00/03/z2ui5_html.js @@ -0,0 +1,73 @@ +/** + * z2ui5_html — HTML escaping for the bootstrap page. + * + * WHY THIS EXISTS + * --------------- + * The bootstrap page (z2ui5_cl_ui5f_index_html, and the equivalent builder in + * z2ui5_cl_ui5_http_handler._http_get) interpolates exit-supplied configuration + * straight into markup: the tab title into , the favicon URI into a + * <link href>, the bootstrap src and theme into script attributes, and every + * t_add_config row into a single-quoted data-sap-ui-* attribute. + * + * None of it was escaped. That was survivable only as long as every value is a + * constant the framework itself sets — but the exit receives the request + * context (init_context / set_config_http_get see path, params and headers), so + * the moment an app reflects a query parameter into its title or into an extra + * config row, the page hands an attacker an injection point. A `'` closes the + * attribute; a `` closes the element. + * + * These helpers make that impossible by construction, and give an exit author + * something to call for their own interpolation: + * + * const { escape_text, escape_attr } = require("abap2UI5/z2ui5_html"); + * + * Escaping is deliberately conservative — the same replacements in both + * helpers plus the quote characters — so a value is safe in element text, in a + * single-quoted attribute and in a double-quoted one alike, and a caller + * cannot pick the wrong one. + */ +"use strict"; + +const REPLACEMENTS = [ + [/&/g, `&`], // first, or it would double-escape the entities below + [//g, `>`], + [/"/g, `"`], + [/'/g, `'`], +]; + +function escape(val) { + let out = val === null || val === undefined ? `` : String(val); + for (const [re, to] of REPLACEMENTS) out = out.replace(re, to); + return out; +} + +/** Escape a value for HTML element text (e.g. between and ). */ +module.exports.escape_text = escape; + +/** Escape a value for an HTML attribute, single- or double-quoted. */ +module.exports.escape_attr = escape; + +/** + * Escape a URI destined for a DOUBLE-QUOTED attribute (href="…", src="…"), + * and reject the schemes a URI context makes dangerous: `javascript:` and + * `vbscript:` execute on click or on load. A rejected value yields the empty + * string, which callers treat as "not configured" and omit the element + * entirely rather than emitting a live but broken attribute. + * + * Escapes only `&` and `"` — the two characters that can end the attribute or + * start an entity in this context. It deliberately does NOT escape `<`, `>` + * or `'`: they are inert inside a double-quoted attribute value, and encoding + * them would corrupt legitimate URIs. The shipped favicon is the worked + * example — `data:image/svg+xml,` has to survive intact, + * and upstream's own test asserts the page contains `data:image/svg+xml, tag by contract, not a value. + const html = require(`../../00/03/z2ui5_html`); const csp = cfg.content_security_policy || ``; - const title = cfg.title || `cap2UI5`; - const theme = cfg.theme || `sap_horizon`; - const src = cfg.src || `https://sdk.openui5.org/resources/sap-ui-cachebuster/sap-ui-core.js`; + const title = html.escape_text(cfg.title || `cap2UI5`); + const theme = html.escape_attr(cfg.theme || `sap_horizon`); + const src = html.escape_uri(cfg.src || `https://sdk.openui5.org/resources/sap-ui-cachebuster/sap-ui-core.js`); - // Extra \t \t${title} - +${favicon} \t\n` + - `