pgkiln 0.43.0 → 0.45.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/db/migrations/100_generated_labels.sql +381 -0
- package/db/migrations/101_theme_choice.sql +15 -0
- package/db/migrations/102_app_computations.sql +345 -0
- package/db/migrations/103_shortcuts.sql +341 -0
- package/db/migrations/104_report_queries.sql +413 -0
- package/db/migrations/105_component_settings_groups.sql +367 -0
- package/docs/apex-feature-parity.md +12 -12
- package/docs/guide/02-concepts.md +24 -0
- package/docs/guide/03-builder.md +48 -9
- package/docs/guide/04-pages-and-regions.md +20 -4
- package/docs/guide/06-processing.md +22 -0
- package/docs/guide/09-reference.md +14 -0
- package/docs/guide/12-development.md +2 -1
- package/docs/guide/14-globalization.md +5 -1
- package/docs/guide/16-files.md +23 -0
- package/docs/guide/18-cli.md +4 -0
- package/package.json +1 -1
- package/public/app.css +40 -1
- package/public/app.js +69 -1
- package/src/appfiles.ts +18 -2
- package/src/builder/advisor.ts +7 -2
- package/src/builder/appsheets.ts +53 -13
- package/src/builder/appwizard.ts +5 -3
- package/src/builder/code-editor.ts +6 -1
- package/src/builder/components.ts +85 -3
- package/src/builder/forms.ts +1 -1
- package/src/builder/shared.ts +39 -6
- package/src/builder/subscriptions.ts +30 -4
- package/src/cli/replace.ts +6 -2
- package/src/i18n/ar.ts +2 -0
- package/src/i18n/cs.ts +2 -0
- package/src/i18n/da.ts +2 -0
- package/src/i18n/de.ts +2 -0
- package/src/i18n/el.ts +2 -0
- package/src/i18n/es.ts +2 -0
- package/src/i18n/fi.ts +2 -0
- package/src/i18n/fr.ts +2 -0
- package/src/i18n/he.ts +2 -0
- package/src/i18n/it.ts +2 -0
- package/src/i18n/ja.ts +2 -0
- package/src/i18n/ko.ts +2 -0
- package/src/i18n/nb.ts +2 -0
- package/src/i18n/pl.ts +2 -0
- package/src/i18n/pt.ts +2 -0
- package/src/i18n/ru.ts +2 -0
- package/src/i18n/sv.ts +2 -0
- package/src/i18n/tr.ts +2 -0
- package/src/i18n/uk.ts +2 -0
- package/src/i18n/zh.ts +2 -0
- package/src/i18n.ts +4 -0
- package/src/metadata.ts +14 -1
- package/src/runtime/account.ts +5 -5
- package/src/runtime/component-settings.ts +42 -0
- package/src/runtime/context.ts +4 -0
- package/src/runtime/devbar.ts +16 -13
- package/src/runtime/documents.ts +29 -3
- package/src/runtime/engine.ts +2 -2
- package/src/runtime/facets.ts +34 -3
- package/src/runtime/grid.ts +22 -4
- package/src/runtime/items.ts +14 -11
- package/src/runtime/locale.ts +4 -1
- package/src/runtime/logic.ts +12 -7
- package/src/runtime/regions.ts +4 -3
- package/src/runtime/render.ts +8 -2
- package/src/runtime/report.ts +16 -10
- package/src/runtime/routes.ts +9 -6
- package/src/runtime/shortcuts.ts +43 -0
- package/src/security.ts +2 -1
- package/src/subscriptions.ts +67 -0
package/src/runtime/regions.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { Button, Region } from '../metadata.ts';
|
|
|
8
8
|
import { isAuthorized, pageAllowed } from './authz.ts';
|
|
9
9
|
import { bindValues, dbg, publicError, stripSemicolon, substitute, timed, type PageContext } from './context.ts';
|
|
10
10
|
import { renderItems } from './items.ts';
|
|
11
|
+
import { expandShortcuts } from './shortcuts.ts';
|
|
11
12
|
import { fillItems, linkAttrs, linkColumns } from './links.ts';
|
|
12
13
|
import { renderCalendar } from './calendar.ts';
|
|
13
14
|
import { CHART_KINDS, renderChartBody, wallClockIn, type GaugeConfig } from './charts.ts';
|
|
@@ -66,7 +67,7 @@ async function renderMenu(ctx: PageContext, b: Button, cls: string, badge: Raw |
|
|
|
66
67
|
for (const e of b.menu ?? []) {
|
|
67
68
|
if (!(await isAuthorized(ctx, e.authz))) continue;
|
|
68
69
|
const ic = e.icon ? html`${icon(e.icon)} ` : '';
|
|
69
|
-
const confirm = e.confirm ? raw(` data-confirm="${esc(e.confirm)}"`) : '';
|
|
70
|
+
const confirm = e.confirm ? raw(` data-confirm="${esc(await expandShortcuts(ctx, e.confirm, 'text'))}"`) : '';
|
|
70
71
|
if (e.page) {
|
|
71
72
|
if (!(await pageAllowed(ctx, e.page))) continue;
|
|
72
73
|
entries.push(html`<a ${linkAttrs(ctx, e.page, e.items ?? {})}${confirm}>${ic}${e.label}</a>`);
|
|
@@ -82,7 +83,7 @@ async function renderMenu(ctx: PageContext, b: Button, cls: string, badge: Raw |
|
|
|
82
83
|
|
|
83
84
|
export async function renderButton(ctx: PageContext, b: Button) {
|
|
84
85
|
const cls = `btn${b.hot ? ' btn-hot' : ''}${b.name === 'DELETE' ? ' btn-danger' : ''}${templateClasses('button', b.template_options)}`;
|
|
85
|
-
const confirm = b.confirm ? raw(` data-confirm="${esc(b.confirm)}"`) : '';
|
|
86
|
+
const confirm = b.confirm ? raw(` data-confirm="${esc(await expandShortcuts(ctx, b.confirm, 'text'))}"`) : '';
|
|
86
87
|
const badge = b.badge || b.badge_query ? await badgeOf(ctx, b) : '';
|
|
87
88
|
if (b.action === 'menu') return renderMenu(ctx, b, cls, badge);
|
|
88
89
|
if (b.action === 'redirect') {
|
|
@@ -401,7 +402,7 @@ async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Pro
|
|
|
401
402
|
break;
|
|
402
403
|
case 'static': {
|
|
403
404
|
const itemsHtml = await renderItems(ctx, items, hidden);
|
|
404
|
-
body = html`${raw(substitute(r.source ?? '', ctx, esc))}${itemsHtml.length ? html`<div class="form-grid">${itemsHtml}</div>` : ''}`;
|
|
405
|
+
body = html`${raw(substitute(await expandShortcuts(ctx, r.source ?? '', 'html'), ctx, esc))}${itemsHtml.length ? html`<div class="form-grid">${itemsHtml}</div>` : ''}`;
|
|
405
406
|
break;
|
|
406
407
|
}
|
|
407
408
|
}
|
package/src/runtime/render.ts
CHANGED
|
@@ -292,9 +292,14 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
|
|
|
292
292
|
</form>
|
|
293
293
|
</div>
|
|
294
294
|
</details>`
|
|
295
|
-
:
|
|
295
|
+
: // (sprint 79, UI-3; APEX: the user menu of a public page) signed out: Appearance and Sign in in the user menu
|
|
296
|
+
ctx.locale.themeChoice || styleChoice(ctx.app)
|
|
297
|
+
? html`<details class="menu t-user"><summary>${icon('user')}<span>${t('login.sign_in_link')}</span></summary>
|
|
298
|
+
<div class="menu-panel align-right">${themeSwitch(ctx, here(ctx))}${styleSwitch(ctx, here(ctx))}
|
|
299
|
+
<div class="menu-section"><a href="${ctx.base}/login">${icon('arrow-right')} ${t('login.sign_in_link')}</a></div></div></details>`
|
|
300
|
+
: html`<a href="${ctx.base}/login">${t('login.sign_in_link')}</a>`
|
|
296
301
|
: ctx.locale.themeChoice || styleChoice(ctx.app)
|
|
297
|
-
? html`<details class="menu t-user"><summary>${icon('settings')}<span class="sr-only">${t('theme.label')}</span></summary>
|
|
302
|
+
? html`<details class="menu t-user"><summary title="${t('theme.label')}">${icon('settings')}<span class="sr-only">${t('theme.label')}</span></summary>
|
|
298
303
|
<div class="menu-panel align-right">${themeSwitch(ctx, here(ctx))}${styleSwitch(ctx, here(ctx))}</div></details>`
|
|
299
304
|
: ''}
|
|
300
305
|
</header>
|
|
@@ -359,6 +364,7 @@ const timeZoneMeta = (ctx: PageContext) =>
|
|
|
359
364
|
|
|
360
365
|
export async function renderPage(ctx: PageContext) {
|
|
361
366
|
const t = ctx.locale.t;
|
|
367
|
+
ctx.wholePage = true;
|
|
362
368
|
const hidden = initiallyHidden(ctx);
|
|
363
369
|
const defaultButton = [...ctx.vis!.buttons.values()].find((b) => b.hot && b.action === 'submit');
|
|
364
370
|
const pageItems = await renderItems(ctx, ctx.page.items.filter((i) => i.region_id === null), hidden);
|
package/src/runtime/report.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { componentSetting } from './component-settings.ts';
|
|
1
2
|
import { icon } from '../icons.ts';
|
|
2
3
|
import pg from 'pg';
|
|
3
4
|
import { templateClasses } from './template-options.ts';
|
|
@@ -237,7 +238,7 @@ export function selectionOf(page: { items: { name: string }[] }, r: Region) {
|
|
|
237
238
|
|
|
238
239
|
export function reportState(ctx: PageContext, r: Region): ReportState {
|
|
239
240
|
const p = ctx.params;
|
|
240
|
-
const size = parseInt(p.get(key(r, 'n')) ?? '', 10) || Number(r.config.page_size) ||
|
|
241
|
+
const size = parseInt(p.get(key(r, 'n')) ?? '', 10) || Number(r.config.page_size) || componentSetting(ctx.app.component_settings, 'report_rows');
|
|
241
242
|
const st: ReportState = {
|
|
242
243
|
search: (p.get(key(r, 'q')) ?? '').trim(),
|
|
243
244
|
searchCol: (p.get(key(r, 'qc')) ?? '').trim() || null,
|
|
@@ -567,17 +568,20 @@ export function pagerNav(ctx: PageContext, r: Region, info: PageInfo, linkAttr:
|
|
|
567
568
|
</nav>`;
|
|
568
569
|
}
|
|
569
570
|
|
|
570
|
-
/** (sprint 76) Rows per page as a select in the pager (a GET form; app.js sends it on change, else its button). */
|
|
571
|
+
/** (sprint 76) Rows per page as a select in the pager (a detached GET form; app.js sends it on change, else its button). */
|
|
571
572
|
function sizeChooser(ctx: PageContext, r: Region, info: PageInfo): Raw | '' {
|
|
572
573
|
if (r.type !== 'report' || r.config.interactive === false) return '';
|
|
573
574
|
const st = reportState(ctx, r);
|
|
574
575
|
const keep = [...ctx.params.entries()].filter(([k]) => k !== key(r, 'n') && k !== key(r, 'p') && k !== key(r, 'k') && !['clear', 'cs'].includes(k));
|
|
575
576
|
const t = ctx.locale.t;
|
|
576
577
|
if (info.total !== null && info.total <= PAGE_SIZES[0] && st.size === PAGE_SIZES[0]) return '';
|
|
577
|
-
|
|
578
|
+
// the region sits inside the page's form: the GET form is detached, the select and its button join it by its id
|
|
579
|
+
const form = `rn${r.id}`;
|
|
580
|
+
ctx.detached.push(html`<form id="${form}" method="get" action="${ctx.base}/${ctx.page.page_no}">${keep.map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}${ctx.dialog ? html`<input type="hidden" name="dialog" value="1">` : ''}</form>`);
|
|
581
|
+
return html`<span class="pager-size">
|
|
578
582
|
<label for="r${r.id}_size">${t('report.rows_per_page')}</label>
|
|
579
|
-
<select id="r${r.id}_size" name="${key(r, 'n')}" data-go>${[...new Set([...PAGE_SIZES, st.size])].sort((a, b) => a - b).map((n) => html`<option value="${n}"${n === st.size ? raw(' selected') : ''}>${n}</option>`)}</select>
|
|
580
|
-
<button class="btn btn-sm no-js-only">${t('report.go')}</button></
|
|
583
|
+
<select id="r${r.id}_size" name="${key(r, 'n')}" form="${form}" data-go>${[...new Set([...PAGE_SIZES, st.size])].sort((a, b) => a - b).map((n) => html`<option value="${n}"${n === st.size ? raw(' selected') : ''}>${n}</option>`)}</select>
|
|
584
|
+
<button class="btn btn-sm no-js-only" form="${form}">${t('report.go')}</button></span>`;
|
|
581
585
|
}
|
|
582
586
|
|
|
583
587
|
/** (sprint 76) The most values a column heading's menu lists. */
|
|
@@ -788,6 +792,8 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
788
792
|
const st = reportState(ctx, r);
|
|
789
793
|
const searchable = r.config.searchable !== false;
|
|
790
794
|
const interactive = r.config.interactive !== false && searchable;
|
|
795
|
+
// (sprint 78; APEX: a faceted search's results have no search bar) one search field: the facets'
|
|
796
|
+
const searchBox = !ctx.page.regions.some((x) => x.type === 'facets' && x.config.search === true && Number(x.config.report) === r.id && ctx.vis?.regions.has(x.id) !== false);
|
|
791
797
|
|
|
792
798
|
let res: pg.QueryResult<any[]>;
|
|
793
799
|
let pageNo = st.page;
|
|
@@ -1124,14 +1130,14 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
1124
1130
|
? html`<div class="report-toolbar">
|
|
1125
1131
|
${filterItems}
|
|
1126
1132
|
${searchable
|
|
1127
|
-
? html`<div class="search" role="search">
|
|
1128
|
-
|
|
1133
|
+
? html`<div class="search"${searchBox ? raw(' role="search"') : ''}>
|
|
1134
|
+
${searchBox ? html`<div class="search-field">${interactive
|
|
1129
1135
|
? html`<label class="sr-only" for="r${r.id}_qc">${t('report.search_column')}</label>
|
|
1130
|
-
<select id="r${r.id}_qc" class="search-scope" name="${key(r, 'qc')}" form="${searchForm}">
|
|
1131
|
-
<option value="">${t('report.all_columns')}</option>${cols.map(({ f }) => html`<option value="${f.name}"${f.name === st.searchCol ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>`
|
|
1136
|
+
<span class="search-scope-wrap" title="${t('report.search_column')}">${icon('search')}<select id="r${r.id}_qc" class="search-scope" name="${key(r, 'qc')}" form="${searchForm}">
|
|
1137
|
+
<option value="">${t('report.all_columns')}</option>${cols.map(({ f }) => html`<option value="${f.name}"${f.name === st.searchCol ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select></span>`
|
|
1132
1138
|
: ''}
|
|
1133
1139
|
<input type="search" name="${key(r, 'q')}" value="${st.search}" placeholder="${t('report.search_all')}" form="${searchForm}" aria-label="${t('report.search')} ${r.title ?? ''}"></div>
|
|
1134
|
-
<button class="btn" form="${searchForm}">${t('report.go')}</button
|
|
1140
|
+
<button class="btn no-js-only" form="${searchForm}">${t('report.go')}</button>` : ''}
|
|
1135
1141
|
${actionsMenu}
|
|
1136
1142
|
</div>`
|
|
1137
1143
|
: ''}
|
package/src/runtime/routes.ts
CHANGED
|
@@ -44,7 +44,7 @@ import { PassThrough } from 'node:stream';
|
|
|
44
44
|
import { reportPdf } from './pdf.ts';
|
|
45
45
|
import { renderDocument } from './documents.ts';
|
|
46
46
|
import { pwaHead } from './pwa.ts';
|
|
47
|
-
import { resolveLocale, THEME_COOKIE, translateApp, translatePage, validTimeZone, type Locale } from './locale.ts';
|
|
47
|
+
import { resolveLocale, THEME_COOKIE, themeCookieOptions, translateApp, translatePage, validTimeZone, type Locale } from './locale.ts';
|
|
48
48
|
import { chrome, dialogClosePage, languagePicker, renderPage } from './render.ts';
|
|
49
49
|
import { LOGIN_ERROR, LOGIN_USER, safeNext, signInForm } from './signin.ts';
|
|
50
50
|
import { passwordResetRoutes } from './password-reset.ts';
|
|
@@ -122,13 +122,14 @@ export async function signIn(req: FastifyRequest, reply: FastifyReply, a: App, o
|
|
|
122
122
|
// the browser's time zone (sent before signing in) stays with the new session
|
|
123
123
|
if (typeof oldSession.state.__TZ === 'string') s.state.__TZ = oldSession.state.__TZ;
|
|
124
124
|
// the account's preferences: light/dark, language and time zone
|
|
125
|
-
const pref = await runtime.one<{ theme_pref: string; language: string | null; time_zone: string | null }>(
|
|
125
|
+
const pref = await runtime.one<{ theme_pref: string | null; language: string | null; time_zone: string | null }>(
|
|
126
126
|
'select theme_pref, language, time_zone from meta.account where lower(username) = lower($1)', [username]);
|
|
127
127
|
if (pref) {
|
|
128
|
-
s
|
|
128
|
+
// (sprint 79) no choice (null): the application's own mode
|
|
129
|
+
if (pref.theme_pref) s.state.__THEME = pref.theme_pref;
|
|
129
130
|
if (pref.language) s.state.__LANG = pref.language;
|
|
130
131
|
if (pref.time_zone) s.state.__TZ_PREF = pref.time_zone;
|
|
131
|
-
if (a.theme?.user_choice !== false) reply.setCookie(THEME_COOKIE, pref.theme_pref,
|
|
132
|
+
if (pref.theme_pref && a.theme?.user_choice !== false) reply.setCookie(THEME_COOKIE, pref.theme_pref, themeCookieOptions(base));
|
|
132
133
|
}
|
|
133
134
|
// the account's style variant for this app (honoured only while the app offers it: styles.ts)
|
|
134
135
|
const style = await runtime.one<{ style: string }>(
|
|
@@ -140,7 +141,8 @@ export async function signIn(req: FastifyRequest, reply: FastifyReply, a: App, o
|
|
|
140
141
|
if (shared) s.state.__SHARED = shared;
|
|
141
142
|
await saveState(s);
|
|
142
143
|
|
|
143
|
-
|
|
144
|
+
// the application processes "after login", then (102) the application computations "after authentication"
|
|
145
|
+
if (a.app_processes.some((p) => p.point === 'after_login') || (a.app_computations ?? []).some((c) => c.point === 'after_authentication')) {
|
|
144
146
|
const home = (await loadPage(a.id, a.home_page)) ?? ({ page_no: a.home_page, items: [], regions: [], buttons: [], dynamic_actions: [], validations: [], processes: [], computations: [], branches: [] } as unknown as Page);
|
|
145
147
|
const ctx: PageContext = {
|
|
146
148
|
app: a, page: home, session: s, base, params: new URLSearchParams(), request: '', user: username,
|
|
@@ -150,6 +152,7 @@ export async function signIn(req: FastifyRequest, reply: FastifyReply, a: App, o
|
|
|
150
152
|
await appTx(txContext(ctx), async (c) => {
|
|
151
153
|
ctx.client = c;
|
|
152
154
|
await runAppProcesses(ctx, 'after_login');
|
|
155
|
+
await runComputations(ctx, 'after_authentication');
|
|
153
156
|
});
|
|
154
157
|
await saveState(s);
|
|
155
158
|
}
|
|
@@ -1024,7 +1027,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
1024
1027
|
if (!item || !vis.editable.has(item.name)) throw new Forbidden(ctx.locale.t('error.unknown_item'));
|
|
1025
1028
|
if (item.config?.cascade_parents) applyPostedItems(ctx, body, list(item.config.cascade_parents));
|
|
1026
1029
|
const page = Math.min(Math.max(Math.floor(Number(body.p)) || 0, 0), 10_000);
|
|
1027
|
-
return searchLov(ctx, item, String(body.q ?? '').slice(0, 200), page, popupPageSize(item, body.n));
|
|
1030
|
+
return searchLov(ctx, item, String(body.q ?? '').slice(0, 200), page, popupPageSize(item, body.n, ctx.app.component_settings));
|
|
1028
1031
|
});
|
|
1029
1032
|
return reply.send(out);
|
|
1030
1033
|
} catch (e) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { esc } from '../html.ts';
|
|
2
|
+
import { publicError, type PageContext } from './context.ts';
|
|
3
|
+
import { computeValue } from './logic.ts';
|
|
4
|
+
|
|
5
|
+
// Shortcuts (migration 103; APEX: Shared Components → Shortcuts). "NAME" (an
|
|
6
|
+
// upper-case name in double quotes) in a static region's content, an item's
|
|
7
|
+
// help text and a button's or menu entry's confirm message is replaced by the
|
|
8
|
+
// application's shortcut of that name, before &ITEM. substitutions. Other
|
|
9
|
+
// quoted words (class="NAV" with no shortcut NAV) stay as they are.
|
|
10
|
+
//
|
|
11
|
+
// text the value; escaped where it goes into HTML
|
|
12
|
+
// html the value as HTML
|
|
13
|
+
// function_body PL/pgSQL returning the text (HTML), run once per request
|
|
14
|
+
// as the application's role
|
|
15
|
+
|
|
16
|
+
const NAME = /"([A-Z][A-Z0-9_]{0,59})"/g;
|
|
17
|
+
|
|
18
|
+
/** A shortcut's text (html: as it goes into HTML), computed once per request; null: no such shortcut. */
|
|
19
|
+
async function valueOf(ctx: PageContext, name: string, asHtml: boolean): Promise<string | null> {
|
|
20
|
+
const s = ctx.app.shortcuts?.find((x) => x.name === name);
|
|
21
|
+
if (!s) return null;
|
|
22
|
+
if (s.type === 'text') return asHtml ? esc(s.value) : s.value;
|
|
23
|
+
if (s.type === 'html') return s.value;
|
|
24
|
+
const cache = (ctx.shortcutValues ??= new Map());
|
|
25
|
+
if (!cache.has(name)) {
|
|
26
|
+
try {
|
|
27
|
+
cache.set(name, (await computeValue(ctx, { type: 'function_body', expression: s.value })) ?? '');
|
|
28
|
+
} catch (e) {
|
|
29
|
+
ctx.errors.page.push(await publicError(ctx, e, `shortcut "${name}"`));
|
|
30
|
+
cache.set(name, '');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return cache.get(name)!;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The text with its "NAME" shortcuts replaced (as HTML or as plain text). */
|
|
37
|
+
export async function expandShortcuts(ctx: PageContext, text: string, as: 'html' | 'text'): Promise<string> {
|
|
38
|
+
if (!ctx.app.shortcuts?.length || !text.includes('"')) return text;
|
|
39
|
+
const names = new Set([...text.matchAll(NAME)].map((m) => m[1]));
|
|
40
|
+
const values = new Map<string, string | null>();
|
|
41
|
+
for (const n of names) values.set(n, await valueOf(ctx, n, as === 'html'));
|
|
42
|
+
return text.replace(NAME, (m, n: string) => values.get(n) ?? m);
|
|
43
|
+
}
|
package/src/security.ts
CHANGED
|
@@ -96,7 +96,8 @@ export function securityHeaders(app: FastifyInstance) {
|
|
|
96
96
|
// the app's own pages may use the camera (photos, scanning) and the position (location items); nothing else may
|
|
97
97
|
reply.header('Permissions-Policy', 'camera=(self), microphone=(), geolocation=(self)');
|
|
98
98
|
const type = String(reply.getHeader('content-type') ?? '');
|
|
99
|
-
|
|
99
|
+
// a file's own stricter policy (downloads, uploaded files: default-src 'none'; sandbox) is kept
|
|
100
|
+
if (type.startsWith('text/html') && !reply.hasHeader('content-security-policy')) {
|
|
100
101
|
// No inline scripts and no inline styles: behaviour lives in /static/app.js,
|
|
101
102
|
// styling in /static/app.css. The only <style> a page has (theme colours,
|
|
102
103
|
// chart geometry) carries this response's nonce; style="" attributes are refused.
|
package/src/subscriptions.ts
CHANGED
|
@@ -219,3 +219,70 @@ export async function publish(masterId: number, kind: string, name: string, user
|
|
|
219
219
|
|
|
220
220
|
export const unsubscribe = async (appId: number, kind: string, name: string) =>
|
|
221
221
|
((await owner.query('delete from meta.subscription where app_id = $1 and kind = $2 and name = $3', [appId, kind, name])).rowCount ?? 0) > 0;
|
|
222
|
+
|
|
223
|
+
// ------------------------------------------------------------------ (105) component groups
|
|
224
|
+
// A component group names some of an application's subscribable components
|
|
225
|
+
// ("kind:name"); another application subscribes to all of them at once.
|
|
226
|
+
|
|
227
|
+
/** The kinds a component group can hold: every subscribable kind but the theme. */
|
|
228
|
+
export const GROUP_KINDS = (Object.keys(KINDS) as Kind[]).filter((k) => k !== 'theme');
|
|
229
|
+
|
|
230
|
+
/** Members as the application has them ("lov:JOBS"), and a message for each that it doesn't have. */
|
|
231
|
+
export async function checkGroupMembers(appId: number, members: string[]) {
|
|
232
|
+
const ok: string[] = [];
|
|
233
|
+
const problems: string[] = [];
|
|
234
|
+
for (const m of members.map((x) => x.trim()).filter(Boolean)) {
|
|
235
|
+
const at = m.indexOf(':');
|
|
236
|
+
const kind = m.slice(0, at).trim();
|
|
237
|
+
const name = m.slice(at + 1).trim();
|
|
238
|
+
if (at < 1 || !isKind(kind) || kind === 'theme') {
|
|
239
|
+
problems.push(`${m}: write kind:name, the kind one of ${GROUP_KINDS.join(', ')}.`);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const k = KINDS[kind];
|
|
243
|
+
const row = await owner.one<{ n: string }>(`select ${k.key} as n from ${k.table} where app_id = $1 and lower(${k.key}) = lower($2)`, [appId, name]);
|
|
244
|
+
if (!row) problems.push(`${m}: the application has no ${k.label.toLowerCase()} ${name}.`);
|
|
245
|
+
else if (!ok.includes(`${kind}:${row.n}`)) ok.push(`${kind}:${row.n}`);
|
|
246
|
+
}
|
|
247
|
+
return { members: ok, problems };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Component groups of the theme and library applications appId can subscribe to. */
|
|
251
|
+
export async function groupsOffered(appId: number) {
|
|
252
|
+
return (
|
|
253
|
+
await owner.query<{ master_id: number; master_name: string; name: string; description: string | null; members: string[] }>(
|
|
254
|
+
`select m.id as master_id, m.name as master_name, g.name, g.description, g.members
|
|
255
|
+
from meta.component_group g join meta.app m on m.id = g.app_id
|
|
256
|
+
where m.app_type in ('theme', 'library') and m.id <> $1 and meta.app_workspace(m.id) = meta.app_workspace($1)
|
|
257
|
+
order by lower(m.name), m.id, lower(g.name)`,
|
|
258
|
+
[appId],
|
|
259
|
+
)
|
|
260
|
+
).rows;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Subscribe appId to every member of a master's component group; members the master no longer has are skipped. */
|
|
264
|
+
export async function subscribeGroup(appId: number, masterId: number, group: string, username: string) {
|
|
265
|
+
const g = await owner.one<{ members: string[] }>('select members from meta.component_group where app_id = $1 and name = $2', [masterId, group]);
|
|
266
|
+
if (!g) throw new SubscriptionError('Choose a component group.');
|
|
267
|
+
const done: string[] = [];
|
|
268
|
+
const skipped: string[] = [];
|
|
269
|
+
for (const m of g.members) {
|
|
270
|
+
const at = m.indexOf(':');
|
|
271
|
+
const kind = m.slice(0, at);
|
|
272
|
+
const name = m.slice(at + 1);
|
|
273
|
+
if (!isKind(kind) || kind === 'theme') continue;
|
|
274
|
+
const k = KINDS[kind];
|
|
275
|
+
if (!(await owner.one(`select 1 as ok from ${k.table} where app_id = $1 and ${k.key} = $2`, [masterId, name]))) {
|
|
276
|
+
skipped.push(m);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
await subscribe(appId, masterId, kind, name, username);
|
|
281
|
+
done.push(m);
|
|
282
|
+
} catch (e) {
|
|
283
|
+
if (!(e instanceof SubscriptionError)) throw e;
|
|
284
|
+
skipped.push(m);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return { done, skipped };
|
|
288
|
+
}
|