pgkiln 0.31.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +2 -2
  2. package/SECURITY.md +10 -0
  3. package/db/migrations/076_form_lost_update.sql +50 -0
  4. package/db/migrations/077_warn_unsaved.sql +41 -0
  5. package/db/migrations/078_url_region.sql +12 -0
  6. package/db/migrations/079_developer_toolbar.sql +53 -0
  7. package/db/migrations/080_search.sql +326 -0
  8. package/db/migrations/081_mail.sql +621 -0
  9. package/db/migrations/082_app_backups.sql +73 -0
  10. package/db/migrations/083_availability.sql +156 -0
  11. package/db/migrations/084_feedback.sql +46 -0
  12. package/db/migrations/085_page_behaviour.sql +65 -0
  13. package/db/migrations/086_data_settings.sql +599 -0
  14. package/docs/apex-feature-parity.md +55 -35
  15. package/docs/guide/01-installation.md +1 -0
  16. package/docs/guide/03-builder.md +118 -2
  17. package/docs/guide/04-pages-and-regions.md +115 -0
  18. package/docs/guide/05-items.md +47 -0
  19. package/docs/guide/06-processing.md +210 -2
  20. package/docs/guide/07-dynamic-actions.md +60 -2
  21. package/docs/guide/08-security.md +13 -2
  22. package/docs/guide/09-reference.md +21 -0
  23. package/docs/guide/11-from-apex.md +5 -1
  24. package/docs/guide/12-development.md +33 -1
  25. package/docs/guide/14-globalization.md +15 -0
  26. package/docs/guide/15-extensions.md +1 -1
  27. package/docs/guide/18-cli.md +30 -1
  28. package/docs/guide/20-ai-agents.md +3 -2
  29. package/examples/hr/hr_50_search.sql +47 -0
  30. package/examples/hr/hr_51_mail.sql +70 -0
  31. package/examples/hr/hr_52_feedback.sql +15 -0
  32. package/examples/hr/hr_53_data_settings.sql +108 -0
  33. package/package.json +3 -1
  34. package/public/app.css +62 -0
  35. package/public/app.js +408 -11
  36. package/public/builder.css +11 -0
  37. package/public/builder.js +27 -0
  38. package/src/app.ts +8 -0
  39. package/src/appfiles.ts +12 -0
  40. package/src/automations.ts +12 -0
  41. package/src/builder/advisor.ts +54 -14
  42. package/src/builder/arrange.ts +2 -2
  43. package/src/builder/availability.ts +74 -0
  44. package/src/builder/backups.ts +134 -0
  45. package/src/builder/components.ts +109 -15
  46. package/src/builder/designer.ts +8 -3
  47. package/src/builder/feedback.ts +82 -0
  48. package/src/builder/forms.ts +4 -1
  49. package/src/builder/home.ts +3 -0
  50. package/src/builder/locks.ts +15 -5
  51. package/src/builder/mail.ts +425 -0
  52. package/src/builder/newapp.ts +1 -1
  53. package/src/builder/region-help.ts +71 -0
  54. package/src/builder/routes.ts +44 -1
  55. package/src/builder/search.ts +4 -4
  56. package/src/builder/shared.ts +3 -2
  57. package/src/builder/ui.ts +7 -1
  58. package/src/cli/apps.ts +46 -5
  59. package/src/cli/main.ts +21 -5
  60. package/src/cli/mcp.ts +16 -4
  61. package/src/cli/replace.ts +36 -4
  62. package/src/geocode.ts +44 -0
  63. package/src/i18n/ar.ts +39 -0
  64. package/src/i18n/cs.ts +39 -0
  65. package/src/i18n/da.ts +39 -0
  66. package/src/i18n/de.ts +39 -0
  67. package/src/i18n/el.ts +39 -0
  68. package/src/i18n/es.ts +39 -0
  69. package/src/i18n/fi.ts +39 -0
  70. package/src/i18n/fr.ts +39 -0
  71. package/src/i18n/he.ts +39 -0
  72. package/src/i18n/it.ts +39 -0
  73. package/src/i18n/ja.ts +39 -0
  74. package/src/i18n/ko.ts +39 -0
  75. package/src/i18n/nb.ts +39 -0
  76. package/src/i18n/pl.ts +39 -0
  77. package/src/i18n/pt.ts +39 -0
  78. package/src/i18n/ru.ts +39 -0
  79. package/src/i18n/sv.ts +39 -0
  80. package/src/i18n/tr.ts +39 -0
  81. package/src/i18n/uk.ts +39 -0
  82. package/src/i18n/zh.ts +39 -0
  83. package/src/i18n.ts +78 -0
  84. package/src/mail/mail.ts +148 -0
  85. package/src/mail/providers.ts +444 -0
  86. package/src/metadata.ts +26 -8
  87. package/src/runtime/ajax.ts +97 -0
  88. package/src/runtime/authz.ts +1 -1
  89. package/src/runtime/availability.ts +37 -0
  90. package/src/runtime/context.ts +46 -6
  91. package/src/runtime/data-export.ts +94 -0
  92. package/src/runtime/data-items.ts +194 -0
  93. package/src/runtime/devbar.ts +182 -0
  94. package/src/runtime/engine.ts +26 -1
  95. package/src/runtime/feedback.ts +128 -0
  96. package/src/runtime/items.ts +8 -0
  97. package/src/runtime/processes.ts +83 -2
  98. package/src/runtime/regions.ts +8 -0
  99. package/src/runtime/render.ts +33 -7
  100. package/src/runtime/rest.ts +4 -0
  101. package/src/runtime/routes.ts +59 -5
  102. package/src/runtime/search.ts +252 -0
  103. package/src/runtime/url-region.ts +93 -0
  104. package/src/security.ts +11 -1
  105. package/src/server.ts +2 -0
  106. package/src/session.ts +8 -2
  107. package/src/workingcopy.ts +2 -2
@@ -0,0 +1,37 @@
1
+ // Application availability (APEX: Application Availability), migration 083.
2
+ // Decides whether a request may use the application: everyone (available),
3
+ // developers with a builder session in the browser (developers), the users
4
+ // named and developers (restricted), or nobody (unavailable). Pages, file
5
+ // downloads and REST modules ask here; PostgREST asks meta.api_check().
6
+ import type { MessageKey } from '../i18n.ts';
7
+
8
+ export type Availability = 'available' | 'developers' | 'restricted' | 'unavailable';
9
+
10
+ export interface AvailabilitySettings {
11
+ availability?: Availability | null;
12
+ availability_message?: string | null;
13
+ availability_users?: string[] | null;
14
+ }
15
+
16
+ export interface Refusal {
17
+ status: 403 | 503;
18
+ /** the default text; the application's own message replaces it when it is unavailable or for developers only */
19
+ key: MessageKey;
20
+ message: string | null;
21
+ /** restricted and not signed in: sign in first */
22
+ signIn?: boolean;
23
+ }
24
+
25
+ /** Null when the request may go on, else why not. */
26
+ export function availabilityRefusal(app: AvailabilitySettings, who: { username: string | null; developer: boolean }): Refusal | null {
27
+ const mode = app.availability ?? 'available';
28
+ if (mode === 'available') return null;
29
+ const message = app.availability_message?.trim() || null;
30
+ if (mode === 'unavailable') return { status: 503, key: 'app.unavailable', message };
31
+ if (who.developer) return null;
32
+ if (mode === 'developers') return { status: 503, key: 'app.developers_only', message };
33
+ // restricted
34
+ if (!who.username) return { status: 403, key: 'app.restricted', message: null, signIn: true };
35
+ const user = who.username.toLowerCase();
36
+ return (app.availability_users ?? []).some((u) => u.toLowerCase() === user) ? null : { status: 403, key: 'app.restricted', message: null };
37
+ }
@@ -1,9 +1,9 @@
1
1
  import type { Download } from './processes.ts';
2
2
  import type { PassThrough } from 'node:stream';
3
3
  import type { PageCss } from '../css.ts';
4
- import type pg from 'pg';
4
+ import pg from 'pg';
5
5
  import type { BindValues } from '../binds.ts';
6
- import type { Client } from '../db.ts';
6
+ import { runtime, type Client } from '../db.ts';
7
7
  import type { Raw } from '../html.ts';
8
8
  import type { App, Button, Page } from '../metadata.ts';
9
9
  import type { Locale } from './locale.ts';
@@ -72,6 +72,16 @@ export interface PageContext {
72
72
  background?: boolean;
73
73
  /** debug messages of this request, when the app's debug level is on (src/debug.ts) */
74
74
  debug?: DebugLog;
75
+ /** (085) values of an Ajax callback's x01 … x10 parameters, as binds X01 … X10 */
76
+ extraBinds?: Record<string, string | null>;
77
+ /** (079) the developer whose builder session is open in this browser (the developer toolbar), if any */
78
+ devbar?: import('./devbar.ts').Devbar | null;
79
+ /** (078) the origins the response may frame (the request's set; security.ts adds them to the CSP) */
80
+ frameSrc?: Set<string> | null;
81
+ /** (086) the https origins of images the response shows (display image items; security.ts adds them to the CSP) */
82
+ imgSrc?: Set<string> | null;
83
+ /** (076) per form region: the checksum of its row when it was fetched (lost update detection) */
84
+ rowVersions?: Map<number, string>;
75
85
  }
76
86
 
77
87
  /** Write a debug message when this request records them (no-op otherwise). */
@@ -89,6 +99,8 @@ export function timed<T>(ctx: { debug?: DebugLog }, level: number, component: st
89
99
  export function bindValues(ctx: PageContext): BindValues {
90
100
  return {
91
101
  ...ctx.session.state,
102
+ // (085) :X01 … :X10 of an Ajax callback (never the built-in names below)
103
+ ...ctx.extraBinds,
92
104
  APP_USER: ctx.user,
93
105
  APP_ID: String(ctx.app.id),
94
106
  APP_ALIAS: ctx.app.alias,
@@ -152,11 +164,13 @@ export async function publicError(ctx: Pick<PageContext, 'app' | 'page' | 'user'
152
164
  const t = ctx.locale?.t ?? english;
153
165
  const err = e as pg.DatabaseError;
154
166
  dbg(ctx, 1, 'error', () => `${where}: ${err.code ? `[${err.code}] ` : ''}${err.message}${err.where ? ` (${err.where.split('\n')[0]})` : ''}`);
155
- if (!err.code) return err.message;
156
- if (err.code === 'P0001') return err.message;
167
+ const handled = (message: string, internal: boolean, reference: string | null = null) =>
168
+ errorFunction(ctx, { message, original_message: err.message ?? String(e), code: err.code ?? null, constraint: err.constraint ?? null, table: err.table ?? null,
169
+ column: err.column ?? null, detail: err.detail ?? null, hint: err.hint ?? null, component: where, page: ctx.page?.page_no ?? null, user: ctx.user, internal, reference });
170
+ if (!err.code || err.code === 'P0001') return handled(err.message, false);
157
171
  const friendly = FRIENDLY[err.code]?.(err, t);
158
172
  if (ctx.app.debug) return `${where}: ${err.message}`;
159
- if (friendly) return friendly;
173
+ if (friendly) return handled(friendly, false);
160
174
  const ref = await logActivity({
161
175
  appId: ctx.app.id,
162
176
  pageNo: ctx.page?.page_no,
@@ -165,7 +179,33 @@ export async function publicError(ctx: Pick<PageContext, 'app' | 'page' | 'user'
165
179
  ip: ctx.ip,
166
180
  detail: `${where}: [${err.code}] ${err.message}`,
167
181
  });
168
- return ref ? t('error.reference', { ref }) : t('error.unexpected');
182
+ return handled(ref ? t('error.reference', { ref }) : t('error.unexpected'), true, ref ?? null);
183
+ }
184
+
185
+ const ERROR_FUNCTION = /^([A-Za-z_][A-Za-z0-9_$]{0,62})\.([A-Za-z_][A-Za-z0-9_$]{0,62})$/;
186
+
187
+ /**
188
+ * (085) The application's error handling function (APEX: Error Handling Function):
189
+ * schema.function(jsonb) returns jsonb, called as the application's role in a
190
+ * transaction of its own (2 s), with the message pgkiln would show and what it
191
+ * knows of the error. When it returns {"message": "…"}, that is shown instead;
192
+ * when it returns null or fails, the message stays.
193
+ */
194
+ async function errorFunction(ctx: Pick<PageContext, 'app' | 'user'> & { debug?: DebugLog }, info: { message: string } & Record<string, unknown>) {
195
+ const fn = ERROR_FUNCTION.exec(ctx.app.error_function ?? '');
196
+ if (!fn) return info.message;
197
+ try {
198
+ const r = await runtime.tx(async (c) => {
199
+ await c.query(`set local statement_timeout = '2s'`);
200
+ await c.query(`select set_config('pgkiln.app_id', $1, true), set_config('pgkiln.app_user', $2, true)`, [String(ctx.app.id), ctx.user]);
201
+ if (ctx.app.db_role) await c.query(`set local role ${pg.escapeIdentifier(ctx.app.db_role)}`);
202
+ return (await c.query(`select ${pg.escapeIdentifier(fn[1])}.${pg.escapeIdentifier(fn[2])}($1::jsonb) as r`, [JSON.stringify(info)])).rows[0]?.r;
203
+ });
204
+ if (r && typeof r === 'object' && typeof r.message === 'string' && r.message.trim()) return r.message.trim().slice(0, 2000);
205
+ } catch (e) {
206
+ dbg(ctx, 1, 'error', () => `error handling function ${ctx.app.error_function}: ${(e as Error).message}`);
207
+ }
208
+ return info.message;
169
209
  }
170
210
 
171
211
  /** Write to a streamed response, waiting while the client is slow; fails once the client is gone. */
@@ -0,0 +1,94 @@
1
+ // The download process's {"export": "<format>"} (migration 086, APEX:
2
+ // APEX_DATA_EXPORT): the process's query returns rows, and the file is
3
+ // those rows as CSV, JSON, XML or HTML (meta.data_export, in SQL) or as
4
+ // Excel or PDF (made here, as the report downloads make them).
5
+ import { applyBinds } from '../binds.ts';
6
+ import { savepoint } from '../db.ts';
7
+ import type { Process } from '../metadata.ts';
8
+ import { writeXlsx, XLSX_TYPE, type XlsxCell } from '../xlsx.ts';
9
+ import { bindValues, stripSemicolon, substitute, type PageContext } from './context.ts';
10
+ import { heading } from './items.ts';
11
+ import { layoutFor, printable, tablePdf } from './pdf.ts';
12
+ import { cell, isNumeric, xlsxCell } from './report.ts';
13
+ import { safeFileName, type Download } from './processes.ts';
14
+
15
+ export const EXPORT_FORMATS = ['csv', 'json', 'xml', 'html', 'xlsx', 'pdf'] as const;
16
+ export type ExportFormat = (typeof EXPORT_FORMATS)[number];
17
+ const TYPES: Record<ExportFormat, string> = {
18
+ csv: 'text/csv; charset=utf-8',
19
+ json: 'application/json',
20
+ xml: 'application/xml',
21
+ html: 'text/html; charset=utf-8',
22
+ xlsx: XLSX_TYPE,
23
+ pdf: 'application/pdf',
24
+ };
25
+ /** Excel and PDF hold at most this many rows (the rest is left out, with a note in the PDF). */
26
+ export const EXPORT_MAX_ROWS = 100_000;
27
+
28
+ export interface ExportConfig {
29
+ export?: ExportFormat;
30
+ /** the file's name without extension (&ITEM. substitutions); default: the process name */
31
+ file_name?: string;
32
+ /** the title of an HTML or PDF file (&ITEM. substitutions) */
33
+ title?: string;
34
+ /** a report layout for the PDF (Shared Components → Report layouts) */
35
+ layout?: string;
36
+ /** CSV: the separator (one character) */
37
+ separator?: string;
38
+ }
39
+
40
+ export function exportProblems(c: ExportConfig): string[] {
41
+ const out: string[] = [];
42
+ if (c.export !== undefined && !EXPORT_FORMATS.includes(c.export)) out.push(`"export" is one of ${EXPORT_FORMATS.join(', ')}.`);
43
+ for (const k of ['file_name', 'title', 'layout'] as const) if (c[k] !== undefined && typeof c[k] !== 'string') out.push(`"${k}" is text.`);
44
+ if (c.separator !== undefined && !(typeof c.separator === 'string' && /^[^"\r\n]$/.test(c.separator))) out.push('"separator" is one character.');
45
+ return out;
46
+ }
47
+
48
+ /** The process's rows as a file in the configured format. */
49
+ export async function exportDownload(ctx: PageContext, p: Process, conf: ExportConfig): Promise<Download> {
50
+ const format = conf.export!;
51
+ const sql = stripSemicolon(applyBinds(p.code ?? '', bindValues(ctx)));
52
+ if (!sql) throw new Error(`Process "${p.name}" needs a query in its code.`);
53
+ const text = (s: string | undefined) => (s ? substitute(s, ctx, (v) => v) : '');
54
+ const name = `${safeFileName(text(conf.file_name) || p.name.toLowerCase(), 'export')}.${format}`;
55
+ const title = text(conf.title) || p.name;
56
+ const c = ctx.client!;
57
+ if (format !== 'xlsx' && format !== 'pdf') {
58
+ const opts = { title: format === 'html' ? title : undefined, separator: conf.separator };
59
+ const r = await savepoint(c, () => c.query<{ f: Buffer }>('select meta.data_export($1, $2, $3) as f', [sql, format, JSON.stringify(opts)]));
60
+ return { content: r.rows[0].f, name, type: TYPES[format], inline: false };
61
+ }
62
+ const res = await savepoint(c, () => c.query({ text: `select * from (\n${sql}\n) "__x" limit ${EXPORT_MAX_ROWS + 1}`, rowMode: 'array' }));
63
+ const fields = res.fields;
64
+ const rows = res.rows as unknown[][];
65
+ const truncated = rows.length > EXPORT_MAX_ROWS;
66
+ if (truncated) rows.length = EXPORT_MAX_ROWS;
67
+ const headings = fields.map((f) => ctx.locale.tr(heading(f.name)));
68
+ if (format === 'xlsx') {
69
+ const cells: XlsxCell[][] = rows.map((r) => r.map((v, i) => xlsxCell(v, fields[i].dataTypeID)));
70
+ return { content: writeXlsx({ name: title, headings, rows: cells }), name, type: TYPES.xlsx, inline: false };
71
+ }
72
+ const t = ctx.locale.t;
73
+ const layout = await layoutFor(ctx.app.id, conf.layout);
74
+ const now = new Date().toISOString();
75
+ const content = await tablePdf(
76
+ {
77
+ title: printable(title),
78
+ header: printable(`${ctx.app.name} · ${now.slice(0, 16).replace('T', ' ')} UTC · ${ctx.user}`),
79
+ footer: printable(title),
80
+ filters: [],
81
+ note: null,
82
+ endNote: () => (truncated ? printable(t('pdf.truncated', { rows: String(EXPORT_MAX_ROWS) })) : null),
83
+ headings: headings.map(printable),
84
+ rows: rows.map((r) => r.map((v, i) => printable(typeof v === 'boolean' ? (v ? t('item.yes') : t('item.no')) : cell(v, fields[i].dataTypeID)))),
85
+ align: fields.map((f) => (isNumeric(f.dataTypeID) ? 'right' : 'left')),
86
+ widths: fields.map(() => null),
87
+ noData: t('report.no_data'),
88
+ pageLabel: (page, pages) => t('pdf.page', { page: String(page), pages: String(pages) }),
89
+ info: { Title: printable(title), Author: ctx.user },
90
+ },
91
+ layout,
92
+ );
93
+ return { content, name, type: TYPES.pdf, inline: false };
94
+ }
@@ -0,0 +1,194 @@
1
+ // Items of migration 086: display image (APEX: Display Image), geocoded
2
+ // address (APEX: Geocoded Address) and items kept per user across sessions
3
+ // (APEX: Maintain session state: Per User, stored in meta.user_preference).
4
+ //
5
+ // image shows the item's value as a picture: an https URL, a path of this
6
+ // server, or a data:image URL. With {"sql": "select photo, photo_mime
7
+ // from hr.emp where id = :P3_ID"} the picture is the first column of
8
+ // the query's first row instead (a BLOB), served by
9
+ // GET /a/<alias>/<page>/image/<item> as the application's role.
10
+ // address a text item with a "Check address" button: the installation's
11
+ // geocoder (GEOCODER_URL, src/geocode.ts) offers matching addresses;
12
+ // the one chosen replaces the text and its position goes into
13
+ // {"coordinates_item": "P3_LOCATION"} as "lat,lng".
14
+ import { createHash } from 'node:crypto';
15
+ import type { FastifyInstance, FastifyReply } from 'fastify';
16
+ import { applyBinds, bindNames } from '../binds.ts';
17
+ import { appTx, savepoint } from '../db.ts';
18
+ import { geocode, geocoderUrl } from '../geocode.ts';
19
+ import { html, raw, type Raw } from '../html.ts';
20
+ import { icon } from '../icons.ts';
21
+ import type { Item } from '../metadata.ts';
22
+ import { checkPageAccess, computeVisibility, Forbidden } from './authz.ts';
23
+ import { bindValues, dbg, publicError, stripSemicolon, substitute, toState, type PageContext } from './context.ts';
24
+
25
+ type Loader = (req: any, reply: FastifyReply, opts?: { json?: boolean }) => Promise<PageContext | null>;
26
+ type Tx = (ctx: PageContext) => Parameters<typeof appTx>[0];
27
+
28
+ /** Picture types an image item serves (an SVG only sandboxed, as everything here). */
29
+ const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/avif', 'image/bmp', 'image/svg+xml']);
30
+ export const IMAGE_MAX_BYTES = 20 * 1024 * 1024;
31
+ const DATA_IMAGE = /^data:image\/(png|jpeg|gif|webp|avif);base64,[A-Za-z0-9+/=\s]+$/;
32
+
33
+ /** A pixel size from the item's config (1–2000), or null. */
34
+ const px = (v: unknown) => {
35
+ const n = Math.round(Number(v));
36
+ return Number.isFinite(n) && n >= 1 && n <= 2000 ? n : null;
37
+ };
38
+
39
+ /** Where an image item's picture comes from: a URL the page may show, or null. */
40
+ export function imageUrl(ctx: PageContext, item: Item, value: string): string | null {
41
+ if (typeof item.config?.sql === 'string' && item.config.sql.trim()) {
42
+ // the query's binds: a new value gives a new URL (the picture is never cached)
43
+ const used = bindNames(item.config.sql).map((n) => `${n}=${ctx.session.state[n] ?? ''}`).join('&');
44
+ const v = createHash('sha256').update(used).digest('base64url').slice(0, 12);
45
+ return `${ctx.base}/${ctx.page.page_no}/image/${item.name}?v=${v}`;
46
+ }
47
+ const s = value.trim();
48
+ if (!s) return null;
49
+ if (DATA_IMAGE.test(s)) return s;
50
+ if (/^\/(?![/\\])/.test(s)) return s;
51
+ try {
52
+ const u = new URL(s);
53
+ if (u.protocol !== 'https:' || u.username || u.password) return null;
54
+ // the page's Content-Security-Policy allows this picture's origin (and no other)
55
+ ctx.imgSrc?.add(u.origin);
56
+ return u.href;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /** The picture of an image item, or a blank display value. */
63
+ export function imageControl(ctx: PageContext, item: Item, value: string): Raw {
64
+ const src = imageUrl(ctx, item, value);
65
+ if (!src) return html`<div class="display-value" id="${item.name}"> </div>`;
66
+ const alt = typeof item.config?.alt === 'string' ? substitute(item.config.alt, ctx, (v) => v) : (item.label ?? '');
67
+ const w = px(item.config?.width);
68
+ const h = px(item.config?.height);
69
+ return html`<div class="display-image" id="${item.name}"><img src="${src}" alt="${alt}"${w ? raw(` width="${w}"`) : ''}${h ? raw(` height="${h}"`) : ''} loading="lazy" decoding="async"></div>`;
70
+ }
71
+
72
+ /** A text input with a "Check address" button (when the installation has a geocoder). */
73
+ export function addressControl(ctx: PageContext, item: Item, value: string, aria: Raw): Raw {
74
+ const id = item.name;
75
+ const input = html`<input type="text" id="${id}" name="${id}" value="${value}" autocomplete="street-address"${aria}>`;
76
+ if (!geocoderUrl()) return input;
77
+ const coords = typeof item.config?.coordinates_item === 'string' && /^[A-Z][A-Z0-9_]*$/.test(item.config.coordinates_item) ? item.config.coordinates_item : '';
78
+ return html`<div class="input-with-button">${input}
79
+ <button type="button" class="btn" data-geocode="${id}" data-url="${ctx.base}/${ctx.page.page_no}/geocode/${id}"${coords ? html` data-coords="${coords}"` : ''} aria-controls="${id}_places">${icon('map')} ${ctx.locale.t('item.check_address')}</button></div>
80
+ <ul class="geocode-places" id="${id}_places" role="listbox" aria-label="${ctx.locale.t('item.address_places')}" hidden></ul>`;
81
+ }
82
+
83
+ // ---------------------------------------------------------------- per-user items
84
+
85
+ /** Items kept per user across sessions ({"persist": "user"}). */
86
+ const persisted = (ctx: PageContext) =>
87
+ ctx.app.authentication === 'none' || !ctx.session.username ? [] : ctx.page.items.filter((i) => i.config?.persist === 'user');
88
+
89
+ /** Before the page renders: the user's values of persisted items not set in this session. */
90
+ export async function loadPreferences(ctx: PageContext) {
91
+ const names = persisted(ctx).map((i) => i.name).filter((n) => ctx.session.state[n] === undefined);
92
+ if (!names.length) return;
93
+ const c = ctx.client!;
94
+ const r = await savepoint(c, () => c.query<{ n: string; v: string | null }>('select n, meta.get_preference(n) as v from unnest($1::text[]) n', [names]));
95
+ for (const { n, v } of r.rows) if (v !== null) ctx.session.state[n] = v;
96
+ dbg(ctx, 6, 'session', `per-user items read: ${names.join(', ')}`);
97
+ }
98
+
99
+ /** After a submit: the user's values of the persisted items the page could change. */
100
+ export async function savePreferences(ctx: PageContext) {
101
+ const items = persisted(ctx).filter((i) => ctx.vis?.editable.has(i.name));
102
+ if (!items.length) return;
103
+ const c = ctx.client!;
104
+ await c.query('select meta.set_preference(n, v) from unnest($1::text[], $2::text[]) as x(n, v)', [
105
+ items.map((i) => i.name),
106
+ items.map((i) => toState(ctx.session.state[i.name])),
107
+ ]);
108
+ }
109
+
110
+ // ---------------------------------------------------------------- routes
111
+
112
+ /** One geocoder request per session and second at most. */
113
+ const lastGeocode = new Map<string, number>();
114
+
115
+ export function dataItemRoutes(app: FastifyInstance, loadContext: Loader, txContext: Tx) {
116
+ app.get('/a/:alias/:page/image/:item', async (req: any, reply) => {
117
+ const ctx = await loadContext(req, reply);
118
+ if (!ctx) return;
119
+ const name = String(req.params.item ?? '').toUpperCase();
120
+ try {
121
+ const file = await appTx(txContext(ctx), async (c) => {
122
+ ctx.client = c;
123
+ await checkPageAccess(ctx);
124
+ await computeVisibility(ctx);
125
+ const item = ctx.page.items.find((i) => i.name === name && i.type === 'image');
126
+ if (!item || !ctx.vis!.items.has(name) || typeof item.config?.sql !== 'string') return null;
127
+ const sql = stripSemicolon(applyBinds(item.config.sql, bindValues(ctx)));
128
+ const r = await savepoint(c, () => c.query({ text: `select * from (\n${sql}\n) "__i" limit 1`, rowMode: 'array' }));
129
+ const [content, mime] = (r.rows[0] ?? []) as [unknown, unknown];
130
+ if (!Buffer.isBuffer(content) || !content.length) return null;
131
+ return { content, mime: String(mime ?? '').toLowerCase().split(';')[0].trim() };
132
+ });
133
+ if (!file) return reply.code(404).header('cache-control', 'no-store').type('text/plain').send('');
134
+ if (file.content.length > IMAGE_MAX_BYTES) return reply.code(413).type('text/plain').send('');
135
+ const type = IMAGE_TYPES.has(file.mime) ? file.mime : sniff(file.content);
136
+ if (!type) return reply.code(415).type('text/plain').send('');
137
+ return reply
138
+ .header('content-security-policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox")
139
+ .header('cache-control', 'private, no-store')
140
+ .type(type)
141
+ .send(file.content);
142
+ } catch (e) {
143
+ if (e instanceof Forbidden) return reply.code(403).type('text/plain').send('');
144
+ req.log.warn({ err: e }, 'image item');
145
+ await publicError(ctx, e, `image of ${name}`);
146
+ return reply.code(500).type('text/plain').send('');
147
+ }
148
+ });
149
+
150
+ app.post('/a/:alias/:page/geocode/:item', async (req: any, reply) => {
151
+ const ctx = await loadContext(req, reply, { json: true });
152
+ if (!ctx) return;
153
+ const body = (req.body ?? {}) as Record<string, unknown>;
154
+ if (body.__csrf !== ctx.session.csrf_token) return reply.code(403).send({ error: ctx.locale.t('error.session_reload') });
155
+ const name = String(req.params.item ?? '').toUpperCase();
156
+ const q = typeof body.q === 'string' ? body.q.trim().slice(0, 300) : '';
157
+ if (!geocoderUrl()) return reply.code(404).send({ error: ctx.locale.t('item.address_error') });
158
+ let item: Item | undefined;
159
+ try {
160
+ await appTx(txContext(ctx), async (c) => {
161
+ ctx.client = c;
162
+ await checkPageAccess(ctx);
163
+ await computeVisibility(ctx);
164
+ item = ctx.page.items.find((i) => i.name === name && i.type === 'address');
165
+ if (!item || !ctx.vis!.editable.has(name)) throw new Forbidden(ctx.locale.t('error.action_unavailable'));
166
+ });
167
+ } catch (e) {
168
+ if (e instanceof Forbidden) return reply.code(403).send({ error: e.message });
169
+ throw e;
170
+ }
171
+ if (q.length < 3) return reply.send({ places: [] });
172
+ const now = Date.now();
173
+ if (now - (lastGeocode.get(ctx.session.id) ?? 0) < 1000) return reply.code(429).send({ error: ctx.locale.t('item.address_error') });
174
+ lastGeocode.set(ctx.session.id, now);
175
+ if (lastGeocode.size > 10_000) for (const [k, t] of lastGeocode) if (now - t > 60_000) lastGeocode.delete(k);
176
+ try {
177
+ const country = typeof item!.config?.country === 'string' ? item!.config.country : null;
178
+ const places = await geocode(q, { language: ctx.locale.lang, country });
179
+ return reply.header('cache-control', 'no-store').send({ places });
180
+ } catch (e) {
181
+ req.log.warn({ err: e }, 'geocoder');
182
+ return reply.code(502).send({ error: ctx.locale.t('item.address_error') });
183
+ }
184
+ });
185
+ }
186
+
187
+ /** The picture type from the first bytes, when the query gave none. */
188
+ function sniff(b: Buffer): string | null {
189
+ if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png';
190
+ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';
191
+ if (b.subarray(0, 4).toString('latin1') === 'GIF8') return 'image/gif';
192
+ if (b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp';
193
+ return null;
194
+ }
@@ -0,0 +1,182 @@
1
+ // Developer toolbar (APEX: Runtime Developer Toolbar) and session state
2
+ // viewer (APEX: Session → View session state), migration 079.
3
+ //
4
+ // A builder session hands the browser a second random token in the cookie
5
+ // pgkiln_devbar (path /a, HttpOnly, SameSite=Lax); the database keeps its
6
+ // sha256 on the builder session (meta.session.devbar_hash). A page of an
7
+ // application shows the toolbar when that token belongs to a live builder
8
+ // session of a developer who may open the application (an administrator, or
9
+ // a member of its workspace). The token does nothing else: it is not the
10
+ // builder session's token, and every builder request still needs that one.
11
+ //
12
+ // The toolbar links to the App Builder, the application and the page in the
13
+ // Page Designer, shows the session state (GET /a/:alias/:page/devbar/session),
14
+ // clears the page's items, and switches debug messages on for this session
15
+ // only (POST …/devbar/debug; meta.session.state.__DEBUG_LEVEL, read by
16
+ // loadContext).
17
+ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
18
+ import { owner, runtime } from '../db.ts';
19
+ import { DEBUG_LEVELS } from '../debug.ts';
20
+ import { html, raw, type Raw } from '../html.ts';
21
+ import { hashToken, newToken } from '../security.ts';
22
+ import { saveState, sessionLimits, type Session } from '../session.ts';
23
+ import { documentShell } from '../layout.ts';
24
+ import type { PageContext } from './context.ts';
25
+
26
+ export const DEVBAR_COOKIE = 'pgkiln_devbar';
27
+ const COOKIE_PATH = '/a';
28
+ const BUILDER = '/builder';
29
+ /** The session state key of the session's own debug level. */
30
+ export const SESSION_DEBUG = '__DEBUG_LEVEL';
31
+
32
+ export interface Devbar {
33
+ username: string;
34
+ }
35
+
36
+ /** Builder: give the browser a toolbar token for this developer session (when it has none that matches). */
37
+ export async function ensureDevbar(req: FastifyRequest, reply: FastifyReply, s: Session) {
38
+ if (!s.username) return;
39
+ const token = req.cookies[DEVBAR_COOKIE];
40
+ if (token && token.length <= 100 && s.devbar_hash && hashToken(token) === s.devbar_hash) return;
41
+ const fresh = newToken();
42
+ s.devbar_hash = hashToken(fresh);
43
+ await runtime.query('update meta.session set devbar_hash = $2 where id = $1 and app_id is null', [s.id, s.devbar_hash]);
44
+ reply.setCookie(DEVBAR_COOKIE, fresh, { path: COOKIE_PATH, httpOnly: true, sameSite: 'lax', secure: process.env.COOKIE_SECURE === 'true' });
45
+ }
46
+
47
+ /** Builder sign-out: the toolbar goes with the session. */
48
+ export function clearDevbar(reply: FastifyReply) {
49
+ reply.clearCookie(DEVBAR_COOKIE, { path: COOKIE_PATH });
50
+ }
51
+
52
+ /** The developer whose live builder session the request's toolbar token belongs to, if they may open this application. */
53
+ export async function devbarOf(req: FastifyRequest, appId: number): Promise<Devbar | null> {
54
+ const token = req.cookies?.[DEVBAR_COOKIE];
55
+ if (!token || token.length > 100) return null;
56
+ const { idleMinutes, maxHours } = sessionLimits();
57
+ const row = await owner.one<{ username: string }>(
58
+ `select s.username from meta.session s
59
+ where s.devbar_hash = $1 and s.app_id is null and s.username is not null
60
+ and s.last_seen > now() - make_interval(mins => $2) and s.created_at > now() - make_interval(hours => $3)
61
+ and exists (select 1 from meta.developer d where d.username = s.username
62
+ and (d.is_admin or exists (select 1 from meta.workspace_member m
63
+ where m.username = d.username and m.workspace_id = meta.app_workspace($4))))`,
64
+ [hashToken(token), idleMinutes, maxHours, appId],
65
+ );
66
+ return row ? { username: row.username } : null;
67
+ }
68
+
69
+ /** The session's own debug level (0: none), set from the toolbar. */
70
+ export const sessionDebugLevel = (s: Session) => {
71
+ const n = Number(s.state[SESSION_DEBUG] ?? 0);
72
+ return DEBUG_LEVELS.some(([level]) => level === n) ? n : 0;
73
+ };
74
+
75
+ /** The toolbar at the bottom of a page (not in dialogs). */
76
+ export function devbarHtml(ctx: PageContext): Raw | '' {
77
+ if (!ctx.devbar || ctx.dialog) return '';
78
+ const level = sessionDebugLevel(ctx.session);
79
+ const appLevel = Number(ctx.app.debug_level ?? 0);
80
+ const page = ctx.page.page_no;
81
+ return html`<details class="devbar" open>
82
+ <summary><span>Developer</span></summary>
83
+ <nav aria-label="Developer toolbar"><ul>
84
+ <li><a href="${BUILDER}">App Builder</a></li>
85
+ <li><a href="${BUILDER}/apps/${ctx.app.id}">Application ${ctx.app.id}</a></li>
86
+ <li><a href="${BUILDER}/pages/${ctx.page.id}">Edit page ${page}</a></li>
87
+ <li><a href="${ctx.base}/${page}/devbar/session">Session</a></li>
88
+ <li><form method="post" action="${ctx.base}/${page}/devbar/debug" class="devbar-debug">
89
+ <input type="hidden" name="__csrf" value="${ctx.session.csrf_token}">
90
+ <label for="devbar-debug">Debug</label>
91
+ <select id="devbar-debug" name="level">${DEBUG_LEVELS.map(([n, label]) =>
92
+ html`<option value="${n}"${n === level ? raw(' selected') : ''}>${n === 0 ? (appLevel ? `Application (${appLevel})` : 'Off') : label}</option>`)}</select>
93
+ <button>Set</button>
94
+ </form></li>
95
+ ${level || appLevel ? html`<li><a href="${BUILDER}/apps/${ctx.app.id}/debug">View debug</a></li>` : ''}
96
+ </ul></nav>
97
+ </details>`;
98
+ }
99
+
100
+ const INTERNAL = /^__/;
101
+
102
+ /** Session state viewer and the toolbar's actions. */
103
+ export function devbarRoutes(app: FastifyInstance, loadContext: (req: any, reply: FastifyReply) => Promise<PageContext | null>) {
104
+ const developerContext = async (req: FastifyRequest, reply: FastifyReply) => {
105
+ const ctx = await loadContext(req, reply);
106
+ if (!ctx) return null;
107
+ if (!ctx.devbar) {
108
+ reply.code(404).type('text/plain').send('Not found');
109
+ return null;
110
+ }
111
+ return ctx;
112
+ };
113
+
114
+ app.get('/a/:alias/:page/devbar/session', async (req, reply) => {
115
+ const ctx = await developerContext(req, reply);
116
+ if (!ctx) return;
117
+ const state = ctx.session.state;
118
+ const show = (name: string, type?: string) => {
119
+ const v = state[name];
120
+ if (v === undefined || v === null) return html`<span class="muted">(null)</span>`;
121
+ if (type === 'password') return html`<span class="muted">(set, not shown)</span>`;
122
+ return html`<code>${v.length > 2000 ? `${v.slice(0, 2000)}…` : v}</code>`;
123
+ };
124
+ const pageItems = ctx.page.items.map((i) => i.name);
125
+ const known = new Set([...pageItems, ...ctx.app.app_items]);
126
+ const others = Object.keys(state).filter((k) => !INTERNAL.test(k) && !known.has(k)).sort();
127
+ const table = (rows: Raw[], cols: string[]) =>
128
+ rows.length
129
+ ? html`<div class="table-wrap"><table class="report"><thead><tr>${cols.map((c) => html`<th scope="col">${c}</th>`)}</tr></thead><tbody>${rows}</tbody></table></div>`
130
+ : html`<p class="muted">None.</p>`;
131
+ const body = html`<main class="t-main devbar-session" id="main">
132
+ <div class="t-titlebar"><h1>Session state</h1></div>
133
+ <div class="t-content">
134
+ <p><a href="${ctx.base}/${ctx.page.page_no}">Back to page ${ctx.page.page_no}</a></p>
135
+ <dl class="devbar-facts">
136
+ <dt>Application</dt><dd>${ctx.app.name} (${ctx.app.alias}, ${ctx.app.id})</dd>
137
+ <dt>User</dt><dd>${ctx.user}</dd>
138
+ <dt>Roles</dt><dd>${ctx.roles.join(', ') || '–'}</dd>
139
+ <dt>Session</dt><dd><code>${ctx.session.id}</code></dd>
140
+ <dt>Language</dt><dd>${ctx.locale.lang}</dd>
141
+ <dt>Time zone</dt><dd>${ctx.locale.timeZone ?? '–'}</dd>
142
+ <dt>Debug</dt><dd>${sessionDebugLevel(ctx.session) ? `level ${sessionDebugLevel(ctx.session)} for this session` : Number(ctx.app.debug_level) ? `level ${ctx.app.debug_level} (application)` : 'off'}</dd>
143
+ </dl>
144
+ <h2>Page ${ctx.page.page_no}: ${ctx.page.name}</h2>
145
+ ${table(ctx.page.items.map((i) => html`<tr><td><code>${i.name}</code></td><td>${i.type}</td><td>${show(i.name, i.type)}</td></tr>`), ['Item', 'Type', 'Value'])}
146
+ <form method="post" action="${ctx.base}/${ctx.page.page_no}/devbar/clear">
147
+ <input type="hidden" name="__csrf" value="${ctx.session.csrf_token}">
148
+ <button class="btn">Clear the items of page ${ctx.page.page_no}</button>
149
+ </form>
150
+ <h2>Application items</h2>
151
+ ${table(ctx.app.app_items.map((n) => html`<tr><td><code>${n}</code></td><td>${show(n)}</td></tr>`), ['Item', 'Value'])}
152
+ <h2>Other values (items of other pages)</h2>
153
+ ${table(others.map((n) => html`<tr><td><code>${n}</code></td><td>${show(n)}</td></tr>`), ['Name', 'Value'])}
154
+ </div>
155
+ </main>`;
156
+ return reply.type('text/html').send(
157
+ documentShell(`Session state · ${ctx.app.name}`, body, 't-app devbar-page', { 'data-base': ctx.base }, '', {
158
+ lang: 'en', dir: 'ltr', theme: ctx.locale.theme, style: ctx.locale.style,
159
+ }),
160
+ );
161
+ });
162
+
163
+ app.post('/a/:alias/:page/devbar/clear', async (req: any, reply) => {
164
+ const ctx = await developerContext(req, reply);
165
+ if (!ctx) return;
166
+ if (req.body?.__csrf !== ctx.session.csrf_token) return reply.code(403).type('text/plain').send('Invalid CSRF token');
167
+ for (const i of ctx.page.items) delete ctx.session.state[i.name];
168
+ await saveState(ctx.session);
169
+ return reply.redirect(`${ctx.base}/${ctx.page.page_no}/devbar/session`, 303);
170
+ });
171
+
172
+ app.post('/a/:alias/:page/devbar/debug', async (req: any, reply) => {
173
+ const ctx = await developerContext(req, reply);
174
+ if (!ctx) return;
175
+ if (req.body?.__csrf !== ctx.session.csrf_token) return reply.code(403).type('text/plain').send('Invalid CSRF token');
176
+ const level = Number(req.body?.level);
177
+ if (level && DEBUG_LEVELS.some(([n]) => n === level)) ctx.session.state[SESSION_DEBUG] = String(level);
178
+ else delete ctx.session.state[SESSION_DEBUG];
179
+ await saveState(ctx.session);
180
+ return reply.redirect(`${ctx.base}/${ctx.page.page_no}`, 303);
181
+ });
182
+ }