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.
- package/README.md +2 -2
- package/SECURITY.md +10 -0
- package/db/migrations/076_form_lost_update.sql +50 -0
- package/db/migrations/077_warn_unsaved.sql +41 -0
- package/db/migrations/078_url_region.sql +12 -0
- package/db/migrations/079_developer_toolbar.sql +53 -0
- package/db/migrations/080_search.sql +326 -0
- package/db/migrations/081_mail.sql +621 -0
- package/db/migrations/082_app_backups.sql +73 -0
- package/db/migrations/083_availability.sql +156 -0
- package/db/migrations/084_feedback.sql +46 -0
- package/db/migrations/085_page_behaviour.sql +65 -0
- package/db/migrations/086_data_settings.sql +599 -0
- package/docs/apex-feature-parity.md +55 -35
- package/docs/guide/01-installation.md +1 -0
- package/docs/guide/03-builder.md +118 -2
- package/docs/guide/04-pages-and-regions.md +115 -0
- package/docs/guide/05-items.md +47 -0
- package/docs/guide/06-processing.md +210 -2
- package/docs/guide/07-dynamic-actions.md +60 -2
- package/docs/guide/08-security.md +13 -2
- package/docs/guide/09-reference.md +21 -0
- package/docs/guide/11-from-apex.md +5 -1
- package/docs/guide/12-development.md +33 -1
- package/docs/guide/14-globalization.md +15 -0
- package/docs/guide/15-extensions.md +1 -1
- package/docs/guide/18-cli.md +30 -1
- package/docs/guide/20-ai-agents.md +3 -2
- package/examples/hr/hr_50_search.sql +47 -0
- package/examples/hr/hr_51_mail.sql +70 -0
- package/examples/hr/hr_52_feedback.sql +15 -0
- package/examples/hr/hr_53_data_settings.sql +108 -0
- package/package.json +3 -1
- package/public/app.css +62 -0
- package/public/app.js +408 -11
- package/public/builder.css +11 -0
- package/public/builder.js +27 -0
- package/src/app.ts +8 -0
- package/src/appfiles.ts +12 -0
- package/src/automations.ts +12 -0
- package/src/builder/advisor.ts +54 -14
- package/src/builder/arrange.ts +2 -2
- package/src/builder/availability.ts +74 -0
- package/src/builder/backups.ts +134 -0
- package/src/builder/components.ts +109 -15
- package/src/builder/designer.ts +8 -3
- package/src/builder/feedback.ts +82 -0
- package/src/builder/forms.ts +4 -1
- package/src/builder/home.ts +3 -0
- package/src/builder/locks.ts +15 -5
- package/src/builder/mail.ts +425 -0
- package/src/builder/newapp.ts +1 -1
- package/src/builder/region-help.ts +71 -0
- package/src/builder/routes.ts +44 -1
- package/src/builder/search.ts +4 -4
- package/src/builder/shared.ts +3 -2
- package/src/builder/ui.ts +7 -1
- package/src/cli/apps.ts +46 -5
- package/src/cli/main.ts +21 -5
- package/src/cli/mcp.ts +16 -4
- package/src/cli/replace.ts +36 -4
- package/src/geocode.ts +44 -0
- package/src/i18n/ar.ts +39 -0
- package/src/i18n/cs.ts +39 -0
- package/src/i18n/da.ts +39 -0
- package/src/i18n/de.ts +39 -0
- package/src/i18n/el.ts +39 -0
- package/src/i18n/es.ts +39 -0
- package/src/i18n/fi.ts +39 -0
- package/src/i18n/fr.ts +39 -0
- package/src/i18n/he.ts +39 -0
- package/src/i18n/it.ts +39 -0
- package/src/i18n/ja.ts +39 -0
- package/src/i18n/ko.ts +39 -0
- package/src/i18n/nb.ts +39 -0
- package/src/i18n/pl.ts +39 -0
- package/src/i18n/pt.ts +39 -0
- package/src/i18n/ru.ts +39 -0
- package/src/i18n/sv.ts +39 -0
- package/src/i18n/tr.ts +39 -0
- package/src/i18n/uk.ts +39 -0
- package/src/i18n/zh.ts +39 -0
- package/src/i18n.ts +78 -0
- package/src/mail/mail.ts +148 -0
- package/src/mail/providers.ts +444 -0
- package/src/metadata.ts +26 -8
- package/src/runtime/ajax.ts +97 -0
- package/src/runtime/authz.ts +1 -1
- package/src/runtime/availability.ts +37 -0
- package/src/runtime/context.ts +46 -6
- package/src/runtime/data-export.ts +94 -0
- package/src/runtime/data-items.ts +194 -0
- package/src/runtime/devbar.ts +182 -0
- package/src/runtime/engine.ts +26 -1
- package/src/runtime/feedback.ts +128 -0
- package/src/runtime/items.ts +8 -0
- package/src/runtime/processes.ts +83 -2
- package/src/runtime/regions.ts +8 -0
- package/src/runtime/render.ts +33 -7
- package/src/runtime/rest.ts +4 -0
- package/src/runtime/routes.ts +59 -5
- package/src/runtime/search.ts +252 -0
- package/src/runtime/url-region.ts +93 -0
- package/src/security.ts +11 -1
- package/src/server.ts +2 -0
- package/src/session.ts +8 -2
- package/src/workingcopy.ts +2 -2
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Search region and search configurations (APEX 22.2: the Search region and
|
|
2
|
+
// Shared Components → Search Configurations), migration 080.
|
|
3
|
+
//
|
|
4
|
+
// A search region searches the configurations it names (all of the
|
|
5
|
+
// application's when it names none) with one search field, or with a page
|
|
6
|
+
// item's value ({"item": "P1_SEARCH"}), and shows the results grouped per
|
|
7
|
+
// configuration, the matched words marked. Each configuration searches:
|
|
8
|
+
//
|
|
9
|
+
// - table: a table or view, its columns quoted as identifiers;
|
|
10
|
+
// - sql: a SELECT (binds allowed), wrapped as a subquery;
|
|
11
|
+
// - list: the entries of a list (Shared Components → Lists) the user may see.
|
|
12
|
+
//
|
|
13
|
+
// Every word of the search text must be found in one of the searchable
|
|
14
|
+
// columns (ILIKE, % and _ escaped, the text as a query parameter). The
|
|
15
|
+
// queries run as the application's role in the page's transaction, each in a
|
|
16
|
+
// savepoint, so one configuration that fails shows its error and the others
|
|
17
|
+
// still answer. Without JavaScript the field is a GET form; app.js searches
|
|
18
|
+
// as the user types (the region endpoint, see routes.ts).
|
|
19
|
+
import pg from 'pg';
|
|
20
|
+
import { applyBinds } from '../binds.ts';
|
|
21
|
+
import { runtime, savepoint } from '../db.ts';
|
|
22
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
23
|
+
import { icon } from '../icons.ts';
|
|
24
|
+
import type { Region } from '../metadata.ts';
|
|
25
|
+
import { bindValues, publicError, stripSemicolon, type PageContext } from './context.ts';
|
|
26
|
+
import { fillItems, linkAttrs } from './links.ts';
|
|
27
|
+
import { listTree, type ListNode } from './lists.ts';
|
|
28
|
+
|
|
29
|
+
const ident = pg.escapeIdentifier;
|
|
30
|
+
|
|
31
|
+
export interface SearchConfig {
|
|
32
|
+
name: string;
|
|
33
|
+
label: string;
|
|
34
|
+
seq: number;
|
|
35
|
+
source_type: 'table' | 'sql' | 'list';
|
|
36
|
+
source: string;
|
|
37
|
+
searchable_columns: string[];
|
|
38
|
+
title_column: string | null;
|
|
39
|
+
subtitle_column: string | null;
|
|
40
|
+
description_column: string | null;
|
|
41
|
+
badge_column: string | null;
|
|
42
|
+
icon: string | null;
|
|
43
|
+
link: { page?: number; items?: Record<string, string> } | null;
|
|
44
|
+
max_results: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SearchRegionConfig {
|
|
48
|
+
configs?: string[];
|
|
49
|
+
item?: string;
|
|
50
|
+
placeholder?: string;
|
|
51
|
+
min_length?: number;
|
|
52
|
+
empty?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** At most this many words of the search text count. */
|
|
56
|
+
const MAX_WORDS = 8;
|
|
57
|
+
const MAX_TEXT = 200;
|
|
58
|
+
const COLUMN = /^[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
|
|
59
|
+
const TABLE = /^([A-Za-z_][A-Za-z0-9_$]{0,62}\.)?[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
|
|
60
|
+
|
|
61
|
+
/** The columns a configuration names (for the Advisor's check of its source). */
|
|
62
|
+
export const configColumns = (c: Pick<SearchConfig, 'title_column' | 'subtitle_column' | 'description_column' | 'badge_column' | 'searchable_columns' | 'icon'>) =>
|
|
63
|
+
[...new Set([c.title_column, c.subtitle_column, c.description_column, c.badge_column, ...(c.searchable_columns ?? []), /^#(.+)#$/.exec(c.icon ?? '')?.[1]]
|
|
64
|
+
.filter((x): x is string => !!x && COLUMN.test(x)))];
|
|
65
|
+
|
|
66
|
+
/** The words of a search text (at most MAX_WORDS, each once, case kept). */
|
|
67
|
+
export function searchWords(text: string | null | undefined) {
|
|
68
|
+
const words = (text ?? '').slice(0, MAX_TEXT).split(/\s+/).map((w) => w.trim()).filter(Boolean);
|
|
69
|
+
return [...new Map(words.map((w) => [w.toLowerCase(), w])).values()].slice(0, MAX_WORDS);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A word as an ILIKE pattern that matches it anywhere, literally. */
|
|
73
|
+
export const likePattern = (word: string) => `%${word.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
|
74
|
+
|
|
75
|
+
/** The parameter of the region's own search field. */
|
|
76
|
+
export const searchParam = (r: Region) => `s${r.id}`;
|
|
77
|
+
|
|
78
|
+
/** Problems of a configuration (the builder shows them; the runtime refuses the configuration). */
|
|
79
|
+
export function searchConfigProblems(c: Partial<SearchConfig>): string[] {
|
|
80
|
+
const out: string[] = [];
|
|
81
|
+
const cols = [c.title_column, c.subtitle_column, c.description_column, c.badge_column, ...(c.searchable_columns ?? [])].filter((x): x is string => !!x);
|
|
82
|
+
if (c.source_type !== 'list') {
|
|
83
|
+
for (const col of cols) if (!COLUMN.test(col)) out.push(`"${col}" is not a column name.`);
|
|
84
|
+
if (!c.title_column) out.push('Choose the column that is the title of each result.');
|
|
85
|
+
}
|
|
86
|
+
if (c.source_type === 'sql' && !/^\s*(select|with)\b/i.test(c.source ?? '')) out.push('The source of an SQL query configuration is a SELECT.');
|
|
87
|
+
if (c.source_type === 'table' && !TABLE.test((c.source ?? '').trim())) out.push('The source of a table configuration is a table or view name, e.g. sales.customers.');
|
|
88
|
+
if (c.icon && !/^(#[A-Za-z_][A-Za-z0-9_$]*#|[a-z0-9-]{1,40})$/.test(c.icon)) out.push('The icon is an icon name, or #COLUMN# for a column that holds icon names.');
|
|
89
|
+
if (c.link && (typeof c.link !== 'object' || !Number.isInteger(c.link.page))) out.push('The link is {"page": 3, "items": {"P3_ID": "#ID#"}}.');
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The configurations a region searches, in sequence order (components of an excluded build option left out). */
|
|
94
|
+
async function configsOf(ctx: PageContext, r: Region): Promise<SearchConfig[]> {
|
|
95
|
+
const conf = (r.config ?? {}) as SearchRegionConfig;
|
|
96
|
+
const names = Array.isArray(conf.configs) ? conf.configs.map((n) => String(n).toUpperCase()) : [];
|
|
97
|
+
return (
|
|
98
|
+
await runtime.query<SearchConfig>(
|
|
99
|
+
`select name, label, seq, source_type, source, searchable_columns, title_column, subtitle_column, description_column,
|
|
100
|
+
badge_column, icon, link, max_results
|
|
101
|
+
from meta.search_config
|
|
102
|
+
where app_id = $1 and meta.build_option_on(app_id, build_option) and (cardinality($2::text[]) = 0 or name = any($2))
|
|
103
|
+
order by seq, name`,
|
|
104
|
+
[ctx.app.id, names],
|
|
105
|
+
)
|
|
106
|
+
).rows;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface Hit {
|
|
110
|
+
title: string;
|
|
111
|
+
subtitle: string | null;
|
|
112
|
+
description: string | null;
|
|
113
|
+
badge: string | null;
|
|
114
|
+
icon: string | null;
|
|
115
|
+
href: Raw | null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const text = (v: unknown) => (v === null || v === undefined ? null : typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
119
|
+
|
|
120
|
+
/** A row's value of a column, case-insensitively. */
|
|
121
|
+
function col(row: Record<string, unknown>, name: string | null) {
|
|
122
|
+
if (!name) return null;
|
|
123
|
+
const key = Object.keys(row).find((k) => k.toLowerCase() === name.toLowerCase());
|
|
124
|
+
return key === undefined ? null : text(row[key]);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** One configuration's results on a table or a query: up to max_results, and whether there are more. */
|
|
128
|
+
async function searchRows(ctx: PageContext, c: SearchConfig, words: string[]): Promise<{ hits: Hit[]; more: boolean }> {
|
|
129
|
+
const searched = (c.searchable_columns.length ? c.searchable_columns : [c.title_column, c.subtitle_column, c.description_column]).filter(
|
|
130
|
+
(x): x is string => !!x,
|
|
131
|
+
);
|
|
132
|
+
const conditions = words.map((_, i) => `(${searched.map((s) => `t.${ident(s)}::text ilike $${i + 1} escape '\\'`).join(' or ')})`);
|
|
133
|
+
let from: string;
|
|
134
|
+
if (c.source_type === 'table') {
|
|
135
|
+
const table = (await ctx.client!.query('select $1::regclass::text as t', [c.source])).rows[0].t as string;
|
|
136
|
+
from = `${table} t`;
|
|
137
|
+
} else from = `(${stripSemicolon(applyBinds(c.source, bindValues(ctx)))}) t`;
|
|
138
|
+
const sql = `select t.* from ${from} where ${conditions.join(' and ') || 'true'}${c.title_column ? ` order by t.${ident(c.title_column)}` : ''} limit ${c.max_results + 1}`;
|
|
139
|
+
const res = await ctx.client!.query(sql, words.map(likePattern));
|
|
140
|
+
const rows = res.rows as Record<string, unknown>[];
|
|
141
|
+
const iconCol = /^#(.+)#$/.exec(c.icon ?? '')?.[1] ?? null;
|
|
142
|
+
return {
|
|
143
|
+
more: rows.length > c.max_results,
|
|
144
|
+
hits: rows.slice(0, c.max_results).map((row) => ({
|
|
145
|
+
title: col(row, c.title_column) ?? '',
|
|
146
|
+
subtitle: col(row, c.subtitle_column),
|
|
147
|
+
description: col(row, c.description_column),
|
|
148
|
+
badge: col(row, c.badge_column),
|
|
149
|
+
icon: iconCol ? col(row, iconCol) : c.icon,
|
|
150
|
+
href:
|
|
151
|
+
c.link && Number.isInteger(c.link.page)
|
|
152
|
+
? linkAttrs(ctx, c.link.page!, fillItems(c.link.items, (name) => col(row, name) ?? undefined))
|
|
153
|
+
: null,
|
|
154
|
+
})),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** One configuration's results on a list: the entries (and their children) the user may see. */
|
|
159
|
+
async function searchList(ctx: PageContext, c: SearchConfig, words: string[]): Promise<{ hits: Hit[]; more: boolean }> {
|
|
160
|
+
const nodes = (await listTree(ctx, c.source)) ?? [];
|
|
161
|
+
const flat: ListNode[] = [];
|
|
162
|
+
const walk = (ns: ListNode[]) => ns.forEach((n) => (flat.push(n), walk(n.children)));
|
|
163
|
+
walk(nodes);
|
|
164
|
+
const found = flat.filter((n) => n.href && words.every((w) => `${n.label} ${n.description ?? ''}`.toLowerCase().includes(w.toLowerCase())));
|
|
165
|
+
return {
|
|
166
|
+
more: found.length > c.max_results,
|
|
167
|
+
hits: found.slice(0, c.max_results).map((n) => ({
|
|
168
|
+
title: n.label,
|
|
169
|
+
subtitle: null,
|
|
170
|
+
description: n.description,
|
|
171
|
+
badge: n.badge,
|
|
172
|
+
icon: n.icon ?? c.icon,
|
|
173
|
+
href: html`href="${n.href}"${n.external ? raw(' rel="noopener noreferrer"') : ''}${n.modal ? raw(' data-dialog') : ''}`,
|
|
174
|
+
})),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The text with the search words marked (<mark>), escaped. */
|
|
179
|
+
export function marked(value: string, words: string[]): Raw {
|
|
180
|
+
if (!words.length || !value) return html`${value}`;
|
|
181
|
+
const re = new RegExp(words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).sort((a, b) => b.length - a.length).join('|'), 'gi');
|
|
182
|
+
const parts: Raw[] = [];
|
|
183
|
+
let last = 0;
|
|
184
|
+
for (const m of value.matchAll(re)) {
|
|
185
|
+
if (!m[0]) continue;
|
|
186
|
+
parts.push(html`${value.slice(last, m.index)}<mark>${m[0]}</mark>`);
|
|
187
|
+
last = m.index! + m[0].length;
|
|
188
|
+
}
|
|
189
|
+
parts.push(html`${value.slice(last)}`);
|
|
190
|
+
return html`${parts}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The search text of a region: its page item's value, or its own field's. */
|
|
194
|
+
export function searchText(ctx: PageContext, r: Region) {
|
|
195
|
+
const conf = (r.config ?? {}) as SearchRegionConfig;
|
|
196
|
+
const item = typeof conf.item === 'string' ? conf.item.toUpperCase() : null;
|
|
197
|
+
return ((item ? ctx.session.state[item] : ctx.params.get(searchParam(r))) ?? '').slice(0, MAX_TEXT);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The results part of a search region (also what app.js swaps in while the user types). */
|
|
201
|
+
async function searchResults(ctx: PageContext, r: Region, query: string): Promise<Raw> {
|
|
202
|
+
const conf = (r.config ?? {}) as SearchRegionConfig;
|
|
203
|
+
const words = searchWords(query);
|
|
204
|
+
const minLength = Number.isInteger(conf.min_length) && conf.min_length! > 0 ? conf.min_length! : 1;
|
|
205
|
+
if (!words.length || query.trim().length < minLength) return html``;
|
|
206
|
+
const groups: Raw[] = [];
|
|
207
|
+
for (const c of await configsOf(ctx, r)) {
|
|
208
|
+
let body: Raw;
|
|
209
|
+
try {
|
|
210
|
+
const problems = searchConfigProblems(c);
|
|
211
|
+
if (problems.length) throw new Error(`Search configuration ${c.name}: ${problems.join(' ')}`);
|
|
212
|
+
const { hits, more } = await savepoint(ctx.client!, () => (c.source_type === 'list' ? searchList(ctx, c, words) : searchRows(ctx, c, words)));
|
|
213
|
+
if (!hits.length) continue;
|
|
214
|
+
body = html`<ul class="search-hits">${hits.map((h) => {
|
|
215
|
+
const inner = html`${h.icon ? html`<span class="search-icon">${icon(h.icon)}</span>` : ''}
|
|
216
|
+
<span class="search-text"><span class="search-title">${marked(h.title, words)}</span>${
|
|
217
|
+
h.subtitle ? html`<span class="search-subtitle">${marked(h.subtitle, words)}</span>` : ''}${
|
|
218
|
+
h.description ? html`<span class="search-description">${marked(h.description, words)}</span>` : ''}</span>
|
|
219
|
+
${h.badge ? html`<span class="badge-pill">${marked(h.badge, words)}</span>` : ''}`;
|
|
220
|
+
return html`<li>${h.href ? html`<a class="search-hit" ${h.href}>${inner}</a>` : html`<div class="search-hit">${inner}</div>`}</li>`;
|
|
221
|
+
})}</ul>${more ? html`<p class="muted search-more">${ctx.locale.t('region.first_rows', { n: String(c.max_results) })}</p>` : ''}`;
|
|
222
|
+
} catch (e) {
|
|
223
|
+
body = html`<div class="alert alert-error" role="alert">${await publicError(ctx, e, `search configuration ${c.name}`)}</div>`;
|
|
224
|
+
}
|
|
225
|
+
// the label is translated like the page's own texts (Shared Components → Globalization)
|
|
226
|
+
groups.push(html`<section class="search-group"><h3>${ctx.locale.tr(c.label)}</h3>${body}</section>`);
|
|
227
|
+
}
|
|
228
|
+
// (a configuration that failed shows its error as a group of its own)
|
|
229
|
+
return groups.length ? html`${groups}` : html`<p class="muted search-empty">${conf.empty ?? ctx.locale.t('lov.no_rows')}</p>`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function renderSearch(ctx: PageContext, r: Region): Promise<Raw> {
|
|
233
|
+
const conf = (r.config ?? {}) as SearchRegionConfig;
|
|
234
|
+
const t = ctx.locale.t;
|
|
235
|
+
const query = searchText(ctx, r);
|
|
236
|
+
const results = html`<div class="search-results" id="R${r.id}_results" aria-live="polite">${await searchResults(ctx, r, query)}</div>`;
|
|
237
|
+
if (typeof conf.item === 'string' && conf.item) return results;
|
|
238
|
+
// its own field: a GET form that keeps the page's other parameters (item values with their checksum)
|
|
239
|
+
const param = searchParam(r);
|
|
240
|
+
const keep = [...ctx.params].filter(([k]) => k !== param);
|
|
241
|
+
const fieldId = `R${r.id}_q`;
|
|
242
|
+
return html`<form method="get" action="${ctx.base}/${ctx.page.page_no}" class="search-form" role="search">
|
|
243
|
+
${keep.map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}
|
|
244
|
+
<label class="label" for="${fieldId}">${t('report.search')}</label>
|
|
245
|
+
<div class="input-with-button">
|
|
246
|
+
<input type="search" id="${fieldId}" name="${param}" value="${query}" maxlength="${MAX_TEXT}" autocomplete="off"
|
|
247
|
+
placeholder="${conf.placeholder ?? t('report.search_placeholder')}" data-search-region="${r.id}">
|
|
248
|
+
<button class="btn">${icon('search')} ${t('report.search')}</button>
|
|
249
|
+
</div>
|
|
250
|
+
</form>
|
|
251
|
+
${results}`;
|
|
252
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// URL region (APEX: URL region): another site's page in the region, in a
|
|
2
|
+
// sandboxed iframe.
|
|
3
|
+
//
|
|
4
|
+
// The region's source is the URL. Its origin must be written out in the
|
|
5
|
+
// source (https://host[:port], or a path on this server): an item can't
|
|
6
|
+
// choose the host. &ITEM. substitutions are allowed after the origin and are
|
|
7
|
+
// URL-encoded. The page's Content-Security-Policy gets frame-src for exactly
|
|
8
|
+
// the origins of its URL regions (security.ts), so nothing else can be framed.
|
|
9
|
+
// The site itself decides whether it may be framed (X-Frame-Options,
|
|
10
|
+
// frame-ancestors): the region has a link to open it in a new tab as well.
|
|
11
|
+
//
|
|
12
|
+
// Attributes: {"height": 480, "sandbox": ["allow-scripts", ...] | false,
|
|
13
|
+
// "fullscreen": true}.
|
|
14
|
+
import { html, type Raw } from '../html.ts';
|
|
15
|
+
import type { Region } from '../metadata.ts';
|
|
16
|
+
import { substitute, type PageContext } from './context.ts';
|
|
17
|
+
|
|
18
|
+
/** The sandbox flags a region may give its frame. */
|
|
19
|
+
export const SANDBOX_FLAGS = [
|
|
20
|
+
'allow-scripts', 'allow-forms', 'allow-popups', 'allow-same-origin', 'allow-downloads', 'allow-modals', 'allow-popups-to-escape-sandbox',
|
|
21
|
+
] as const;
|
|
22
|
+
const DEFAULT_SANDBOX = ['allow-scripts', 'allow-forms', 'allow-popups', 'allow-same-origin'];
|
|
23
|
+
const LOCAL = /^(localhost|127\.0\.0\.1|\[::1\])$/i;
|
|
24
|
+
|
|
25
|
+
export interface UrlRegionConfig {
|
|
26
|
+
height?: number;
|
|
27
|
+
sandbox?: string[] | false;
|
|
28
|
+
fullscreen?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The origin a URL region frames: { origin } for an https URL (http only for
|
|
33
|
+
* localhost), { origin: null } for a path on this server, or { error }.
|
|
34
|
+
*/
|
|
35
|
+
export function urlTarget(source: string | null | undefined): { origin: string | null } | { error: string } {
|
|
36
|
+
const s = (source ?? '').trim();
|
|
37
|
+
if (!s) return { error: 'A URL region needs a URL in its source.' };
|
|
38
|
+
if (/^\/(?![/\\])/.test(s)) return { origin: null };
|
|
39
|
+
// the scheme and host are literal: no &ITEM. before the path
|
|
40
|
+
const m = /^(https?):\/\/([^/?#&\\\s]+)(?=[/?#]|$)/i.exec(s);
|
|
41
|
+
if (!m) return { error: 'A URL region needs an https:// URL (or a path on this server starting with /); its host can\'t come from an item.' };
|
|
42
|
+
let u: URL;
|
|
43
|
+
try {
|
|
44
|
+
u = new URL(`${m[1]}://${m[2]}/`);
|
|
45
|
+
} catch {
|
|
46
|
+
return { error: 'The URL of this region is not valid.' };
|
|
47
|
+
}
|
|
48
|
+
if (u.username || u.password) return { error: 'A URL region\'s URL can\'t contain a user name or password.' };
|
|
49
|
+
if (u.protocol === 'http:' && !LOCAL.test(u.hostname)) return { error: 'A URL region needs an https:// URL (http:// only for localhost).' };
|
|
50
|
+
return { origin: u.origin };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The checks of a region's attributes (the builder shows them; the runtime ignores what fails). */
|
|
54
|
+
export function urlConfigProblem(config: unknown): string | null {
|
|
55
|
+
const c = (config ?? {}) as UrlRegionConfig;
|
|
56
|
+
if (c.height !== undefined && !(Number.isInteger(c.height) && c.height >= 100 && c.height <= 4000)) return 'height: a whole number of pixels from 100 to 4000.';
|
|
57
|
+
if (c.sandbox !== undefined && c.sandbox !== false) {
|
|
58
|
+
if (!Array.isArray(c.sandbox)) return 'sandbox: a list of flags, or false for no sandbox.';
|
|
59
|
+
const bad = c.sandbox.filter((f) => !(SANDBOX_FLAGS as readonly string[]).includes(f));
|
|
60
|
+
if (bad.length) return `sandbox: unknown flag ${bad[0]} (allowed: ${SANDBOX_FLAGS.join(', ')}).`;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The origins the page's URL regions frame (for the Content-Security-Policy's frame-src). */
|
|
66
|
+
export function frameOrigins(regions: Region[]) {
|
|
67
|
+
const out = new Set<string>();
|
|
68
|
+
for (const r of regions) {
|
|
69
|
+
if (r.type !== 'url') continue;
|
|
70
|
+
const t = urlTarget(r.source);
|
|
71
|
+
if (!('error' in t) && t.origin) out.add(t.origin);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function renderUrlRegion(ctx: PageContext, r: Region): Raw {
|
|
77
|
+
const t = ctx.locale.t;
|
|
78
|
+
const target = urlTarget(r.source);
|
|
79
|
+
if ('error' in target) return html`<div class="alert alert-error" role="alert">${target.error}</div>`;
|
|
80
|
+
const url = substitute((r.source ?? '').trim(), ctx, encodeURIComponent);
|
|
81
|
+
// the substituted URL still goes to the same origin (substitutions are encoded, but check anyway)
|
|
82
|
+
const resolved = new URL(url, 'https://same.origin.invalid');
|
|
83
|
+
if (target.origin ? resolved.origin !== target.origin : resolved.origin !== 'https://same.origin.invalid')
|
|
84
|
+
return html`<div class="alert alert-error" role="alert">The URL of this region is not valid.</div>`;
|
|
85
|
+
const c = (r.config ?? {}) as UrlRegionConfig;
|
|
86
|
+
const problem = urlConfigProblem(c);
|
|
87
|
+
const height = !problem && c.height ? c.height : 480;
|
|
88
|
+
const sandbox = !problem && c.sandbox === false ? null : (!problem && Array.isArray(c.sandbox) ? c.sandbox : DEFAULT_SANDBOX).join(' ');
|
|
89
|
+
const cls = `url-frame ${ctx.css.cls(`height:${height}px`)}`;
|
|
90
|
+
return html`<iframe class="${cls}" src="${url}" title="${r.title ?? t('region.url_frame')}" loading="lazy" referrerpolicy="strict-origin-when-cross-origin"${
|
|
91
|
+
sandbox === null ? '' : html` sandbox="${sandbox}"`}${c.fullscreen === true ? html` allowfullscreen` : ''}></iframe>
|
|
92
|
+
<p class="url-open"><a href="${url}" target="_blank" rel="noopener noreferrer">${t('region.url_open')}</a></p>`;
|
|
93
|
+
}
|
package/src/security.ts
CHANGED
|
@@ -69,6 +69,10 @@ declare module 'fastify' {
|
|
|
69
69
|
interface FastifyRequest {
|
|
70
70
|
/** The CSP nonce of this response: the only inline <style> allowed carries it. */
|
|
71
71
|
cspNonce: string;
|
|
72
|
+
/** (078) origins this page frames (URL regions): the CSP's frame-src */
|
|
73
|
+
frameSrc: Set<string> | null;
|
|
74
|
+
/** (086) https origins of the page's display image items: added to the CSP's img-src */
|
|
75
|
+
imgSrc: Set<string> | null;
|
|
72
76
|
}
|
|
73
77
|
}
|
|
74
78
|
|
|
@@ -78,8 +82,12 @@ const MAP_TILES = tileOrigin();
|
|
|
78
82
|
/** Security headers for every response. */
|
|
79
83
|
export function securityHeaders(app: FastifyInstance) {
|
|
80
84
|
app.decorateRequest('cspNonce', '');
|
|
85
|
+
app.decorateRequest('frameSrc', null);
|
|
86
|
+
app.decorateRequest('imgSrc', null);
|
|
81
87
|
app.addHook('onRequest', async (req) => {
|
|
82
88
|
req.cspNonce = randomBytes(18).toString('base64');
|
|
89
|
+
req.frameSrc = new Set();
|
|
90
|
+
req.imgSrc = new Set();
|
|
83
91
|
});
|
|
84
92
|
app.addHook('onSend', async (req, reply, payload) => {
|
|
85
93
|
reply.header('X-Content-Type-Options', 'nosniff');
|
|
@@ -94,7 +102,9 @@ export function securityHeaders(app: FastifyInstance) {
|
|
|
94
102
|
// chart geometry) carries this response's nonce; style="" attributes are refused.
|
|
95
103
|
reply.header(
|
|
96
104
|
'Content-Security-Policy',
|
|
97
|
-
`default-src 'self'; script-src 'self'; style-src 'self' 'nonce-${req.cspNonce}'; img-src 'self' data:${MAP_TILES ? ` ${MAP_TILES}` : ''}; ` +
|
|
105
|
+
`default-src 'self'; script-src 'self'; style-src 'self' 'nonce-${req.cspNonce}'; img-src 'self' data:${MAP_TILES ? ` ${MAP_TILES}` : ''}${req.imgSrc?.size ? ` ${[...req.imgSrc].join(' ')}` : ''}; ` +
|
|
106
|
+
// (078) a page with URL regions may frame exactly their origins (literal https origins, see url-region.ts)
|
|
107
|
+
(req.frameSrc?.size ? `frame-src 'self' ${[...req.frameSrc].join(' ')}; ` : '') +
|
|
98
108
|
"frame-ancestors 'self'; form-action 'self'; base-uri 'none'; object-src 'none'",
|
|
99
109
|
);
|
|
100
110
|
// Pages contain user data: never cache them (e.g. back button after sign-out).
|
package/src/server.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { startScheduler } from './automations.ts';
|
|
|
4
4
|
import { startWorkflowRunner } from './workflow.ts';
|
|
5
5
|
import { startProcessJobRunner } from './process-jobs.ts';
|
|
6
6
|
import { startPushListener } from './push.ts';
|
|
7
|
+
import { startMailListener } from './mail/mail.ts';
|
|
7
8
|
|
|
8
9
|
const app = await buildApp();
|
|
9
10
|
await app.listen({ port: Number(process.env.PORT ?? 3100), host: process.env.HOST ?? '127.0.0.1' });
|
|
@@ -12,3 +13,4 @@ startScheduler();
|
|
|
12
13
|
await startWorkflowRunner();
|
|
13
14
|
await startProcessJobRunner();
|
|
14
15
|
await startPushListener();
|
|
16
|
+
await startMailListener();
|
package/src/session.ts
CHANGED
|
@@ -15,6 +15,10 @@ export interface Session {
|
|
|
15
15
|
/** Builder only: the developer's workspaces and the current one (src/builder/workspaces.ts). */
|
|
16
16
|
workspaces?: { id: number; name: string }[];
|
|
17
17
|
workspace?: { id: number; name: string } | null;
|
|
18
|
+
/** Builder only (079): sha256 of the developer toolbar token (src/runtime/devbar.ts). */
|
|
19
|
+
devbar_hash?: string | null;
|
|
20
|
+
/** (085) when the session started, in ms since the epoch (its maximum length counts from here) */
|
|
21
|
+
created_ms?: number;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
// instance settings (src/instance.ts): the builder's value, else SESSION_* environment variables, else 60 minutes / 8 hours
|
|
@@ -23,6 +27,8 @@ const MAX_HOURS = () => instanceSetting('session_max_hours');
|
|
|
23
27
|
const secure = () => process.env.COOKIE_SECURE === 'true';
|
|
24
28
|
|
|
25
29
|
export const cookieName = (appId: number | null) => (appId === null ? 'pgkiln_dev' : `pgkiln_app_${appId}`);
|
|
30
|
+
/** The idle and maximum session length (minutes, hours), for lookups outside this file. */
|
|
31
|
+
export const sessionLimits = () => ({ idleMinutes: IDLE_MINUTES(), maxHours: MAX_HOURS() });
|
|
26
32
|
|
|
27
33
|
async function find(token: string | undefined, appId: number | null) {
|
|
28
34
|
if (!token || token.length > 100) return undefined;
|
|
@@ -31,7 +37,7 @@ async function find(token: string | undefined, appId: number | null) {
|
|
|
31
37
|
where token_hash = $1 and app_id is not distinct from $2
|
|
32
38
|
and last_seen > now() - make_interval(mins => $3)
|
|
33
39
|
and created_at > now() - make_interval(hours => $4)
|
|
34
|
-
returning id, app_id, username, csrf_token, state, roles`,
|
|
40
|
+
returning id, app_id, username, csrf_token, state, roles, devbar_hash, (extract(epoch from created_at) * 1000)::float8 as created_ms`,
|
|
35
41
|
[hashToken(token), appId, IDLE_MINUTES(), MAX_HOURS()],
|
|
36
42
|
);
|
|
37
43
|
}
|
|
@@ -44,7 +50,7 @@ export async function createSession(reply: FastifyReply, appId: number | null, p
|
|
|
44
50
|
const token = newToken();
|
|
45
51
|
const s = (await runtime.one<Session>(
|
|
46
52
|
`insert into meta.session (token_hash, app_id, username, roles) values ($1, $2, $3, $4)
|
|
47
|
-
returning id, app_id, username, csrf_token, state, roles`,
|
|
53
|
+
returning id, app_id, username, csrf_token, state, roles, (extract(epoch from created_at) * 1000)::float8 as created_ms`,
|
|
48
54
|
[hashToken(token), appId, username, roles.map((r) => r.toLowerCase())],
|
|
49
55
|
))!;
|
|
50
56
|
if (Math.random() < 0.05)
|
package/src/workingcopy.ts
CHANGED
|
@@ -341,8 +341,8 @@ export async function mergeCopy(copyId: number, o: MergeOptions) {
|
|
|
341
341
|
} catch (e) {
|
|
342
342
|
throw new WorkingCopyError(`The merged application is not consistent: ${(e as Error).message}. Resolve it in the working copy (or the main application) first.`);
|
|
343
343
|
}
|
|
344
|
-
if (o.direction === 'merge') await replaceApp(c, doc, l.mainAlias);
|
|
345
|
-
await replaceApp(c, doc, l.copy.alias);
|
|
344
|
+
if (o.direction === 'merge') await replaceApp(c, doc, l.mainAlias, { backup: { reason: 'merge', user: o.username, note: `merge of working copy ${l.copy.alias}` } });
|
|
345
|
+
await replaceApp(c, doc, l.copy.alias, { backup: { reason: 'merge', user: o.username } });
|
|
346
346
|
// the copy keeps running nothing on its own
|
|
347
347
|
await c.query('update meta.automation set enabled = false where app_id = $1', [copyId]);
|
|
348
348
|
await c.query('update meta.rest_source set sync_enabled = false where app_id = $1', [copyId]);
|