pgkiln 0.52.0 → 0.53.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 +1 -1
- package/SECURITY.md +5 -1
- package/db/migrations/125_app_change.sql +23 -0
- package/db/migrations/126_mail_queue_network_access.sql +119 -0
- package/docs/apex-feature-parity.md +40 -11
- package/docs/guide/01-installation.md +1 -1
- package/docs/guide/03-builder.md +76 -3
- package/docs/guide/06-processing.md +6 -1
- package/docs/guide/08-security.md +55 -0
- package/docs/guide/09-reference.md +3 -1
- package/docs/guide/11-from-apex.md +1 -0
- package/docs/guide/12-development.md +43 -8
- package/docs/guide/14-globalization.md +49 -1
- package/docs/guide/18-cli.md +21 -1
- package/docs/guide/19-rest-data-sources.md +12 -0
- package/docs/guide/20-ai-agents.md +8 -2
- package/examples/hr/hr_70_data_security.sql +79 -0
- package/package.json +1 -1
- package/public/app.css +97 -23
- package/public/builder.css +50 -0
- package/public/builder.js +4 -0
- package/src/app.ts +9 -0
- package/src/builder/app-updates.ts +55 -0
- package/src/builder/data-security.ts +182 -0
- package/src/builder/home.ts +16 -5
- package/src/builder/mail-queue.ts +212 -0
- package/src/builder/mail.ts +72 -45
- package/src/builder/network-access.ts +140 -0
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/object-browser.ts +1 -1
- package/src/builder/rest-catalogs.ts +19 -4
- package/src/builder/sql.ts +2 -0
- package/src/builder/themeroller.ts +47 -6
- package/src/builder/ui.ts +5 -0
- package/src/builder/websources.ts +1 -1
- package/src/builder/workspace-mail.ts +121 -0
- package/src/builder/workspaces.ts +1 -0
- package/src/cli/main.ts +25 -3
- package/src/cli/mcp.ts +40 -4
- package/src/cli/only.ts +52 -0
- package/src/cli/replace.ts +5 -1
- package/src/datasec.ts +343 -0
- package/src/mail/mail.ts +50 -11
- package/src/mail/providers.ts +6 -3
- package/src/metadata.ts +2 -0
- package/src/network-acl.ts +126 -0
- package/src/objectstore.ts +3 -0
- package/src/runtime/engine.ts +3 -1
- package/src/runtime/render.ts +4 -2
- package/src/runtime/styles.ts +54 -12
- package/src/webclient.ts +45 -4
- package/src/webrequests.ts +5 -0
- package/src/websources.ts +30 -12
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
|
+
import { owner } from '../db.ts';
|
|
3
|
+
import { html, raw } from '../html.ts';
|
|
4
|
+
import { icon } from '../icons.ts';
|
|
5
|
+
import { parseHostList, scopeForWorkspace } from '../network-acl.ts';
|
|
6
|
+
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
7
|
+
import { matches, restHosts, scopeProblem, urlProblem } from '../webclient.ts';
|
|
8
|
+
import { clearResponseCache } from '../websources.ts';
|
|
9
|
+
import { isAdmin } from './locks.ts';
|
|
10
|
+
import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
|
|
11
|
+
import { workspaceAdmin } from './workspaces.ts';
|
|
12
|
+
|
|
13
|
+
// (sprint 118, WS-9; APEX 26.2 workspace network ACLs) Administration →
|
|
14
|
+
// Network access: the installation's allow-list (PGKILN_REST_ALLOWED_HOSTS,
|
|
15
|
+
// read-only: the server's environment), a workspace's own network access list
|
|
16
|
+
// (meta.workspace.network_restricted / network_hosts, src/network-acl.ts),
|
|
17
|
+
// "would this URL be called?", and the recent refusals. Administrators see and
|
|
18
|
+
// change every workspace's list; a workspace administrator their current
|
|
19
|
+
// workspace's only.
|
|
20
|
+
|
|
21
|
+
const id = (v: unknown) => (/^\d{1,9}$/.test(String(v ?? '')) ? Number(v) : null);
|
|
22
|
+
|
|
23
|
+
/** The workspace this developer may manage here (?ws=, else the current one; administrators: any), or null. */
|
|
24
|
+
async function manageable(s: Session, wanted: unknown): Promise<{ admin: boolean; workspace: number } | null> {
|
|
25
|
+
const admin = await isAdmin(s.username);
|
|
26
|
+
// ?ws= or the current workspace; a workspace administrator only one they administer
|
|
27
|
+
const ws = id(wanted) ?? s.workspace?.id ?? (admin ? 1 : null);
|
|
28
|
+
if (!ws || !(await workspaceAdmin(s.username, ws))) return null;
|
|
29
|
+
return { admin, workspace: ws };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function refuse(s: Session, reply: FastifyReply) {
|
|
33
|
+
const main = html`<h1 class="u-mb1">Network access</h1><div class="alert alert-error" role="alert">Only administrators and workspace administrators manage network access.</div>`;
|
|
34
|
+
await send(reply.code(403), s, shell(s, 'Network access', [['Administration'], ['Network access']], main, 'users'));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const when = (d: Date | string | null) => (d ? new Date(d).toISOString().slice(0, 16).replace('T', ' ') : '');
|
|
38
|
+
const entries = (list: string[]) => (list.length ? html`<ul class="host-list">${list.map((h) => html`<li><code>${h}</code></li>`)}</ul>` : html`<p class="muted">None.</p>`);
|
|
39
|
+
|
|
40
|
+
export async function networkAccessRoutes(app: FastifyInstance) {
|
|
41
|
+
app.get(`${BASE}/network`, async (req: Req, reply) => {
|
|
42
|
+
const s = await developer(req, reply);
|
|
43
|
+
if (!s) return;
|
|
44
|
+
const m = await manageable(s, req.query.ws);
|
|
45
|
+
if (!m) return refuse(s, reply);
|
|
46
|
+
const w = await owner.one<{ id: number; name: string; network_restricted: boolean; network_hosts: string[] }>(
|
|
47
|
+
'select id, name, network_restricted, network_hosts from meta.workspace where id = $1', [m.workspace]);
|
|
48
|
+
if (!w) return reply.code(404).send('Not found');
|
|
49
|
+
const inst = restHosts();
|
|
50
|
+
const [all, refusals] = await Promise.all([
|
|
51
|
+
m.admin ? owner.query('select id, name, network_restricted, cardinality(network_hosts)::int as n from meta.workspace order by id = 1 desc, lower(name)').then((r) => r.rows) : [],
|
|
52
|
+
owner.query(
|
|
53
|
+
`select r.at, r.what, r.host, r.url, r.list, r.message, a.alias from meta.network_refusal r left join meta.app a on a.id = r.app_id
|
|
54
|
+
where r.workspace_id = $1 order by r.id desc limit 100`, [w.id]).then((r) => r.rows),
|
|
55
|
+
]);
|
|
56
|
+
// entries the installation's list never lets through (they narrow nothing: worth a warning)
|
|
57
|
+
const unreachable = w.network_hosts.filter((h) => {
|
|
58
|
+
const host = h.replace(/^\*\./, 'x.');
|
|
59
|
+
try {
|
|
60
|
+
return !matches([...inst.allowed, ...inst.private], new URL(`https://${host}`)) && !inst.allowed.includes('*');
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
const tryUrl = String(req.query.url ?? '').trim().slice(0, 2000);
|
|
66
|
+
let verdict: { ok: boolean; text: string } | null = null;
|
|
67
|
+
if (tryUrl) {
|
|
68
|
+
let url: URL | null = null;
|
|
69
|
+
try {
|
|
70
|
+
url = new URL(tryUrl);
|
|
71
|
+
} catch {
|
|
72
|
+
verdict = { ok: false, text: 'The URL is not valid.' };
|
|
73
|
+
}
|
|
74
|
+
if (url) {
|
|
75
|
+
const scope = await scopeForWorkspace(w.id, 'check');
|
|
76
|
+
const problem = urlProblem(url) ?? scopeProblem(scope, url);
|
|
77
|
+
verdict = problem ? { ok: false, text: problem } : { ok: true, text: `${url.host} may be called by this workspace's applications (the address it resolves to is checked when the call is made).` };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const main = html`
|
|
81
|
+
<div class="title-row"><h1>Network access</h1></div>
|
|
82
|
+
<p class="muted u-mt0">Which web services the applications may call: <code>meta.web_request</code>, REST data sources, the Invoke API process and workflow step, web credential token endpoints, object storage and REST source catalogs. The installation's list applies to every workspace; a workspace's own list can only narrow it, never widen it. Private and loopback addresses stay refused unless the installation allows them, whatever a workspace lists.</p>
|
|
83
|
+
<div class="columns wide-left">
|
|
84
|
+
${region(`Workspace ${w.name}`, html`<form method="post" action="${BASE}/network">${csrf(s)}<input type="hidden" name="ws" value="${w.id}">
|
|
85
|
+
<fieldset class="field"><legend class="label">Outgoing calls of this workspace's applications</legend><div class="radio-group">
|
|
86
|
+
<label class="check"><input type="radio" name="restricted" value="false"${w.network_restricted ? '' : raw(' checked')}> Any host the installation allows</label>
|
|
87
|
+
<label class="check"><input type="radio" name="restricted" value="true"${w.network_restricted ? raw(' checked') : ''}> Only the hosts below (and the installation must allow them too)</label>
|
|
88
|
+
</div></fieldset>
|
|
89
|
+
<div class="field"><label class="label" for="f_hosts">Hosts</label>
|
|
90
|
+
<textarea id="f_hosts" name="hosts" rows="6" spellcheck="false">${w.network_hosts.join('\n')}</textarea>
|
|
91
|
+
<small class="help">One per line: <code>api.example.com</code>, <code>*.example.com</code> for its subdomains, <code>api.example.com:8443</code> for one port only. No scheme or path. Restricted with no hosts: no outgoing calls at all.</small></div>
|
|
92
|
+
${unreachable.length ? html`<div class="alert alert-warning" role="status">Not on the installation's list, so never reachable: ${unreachable.join(', ')}.</div>` : ''}
|
|
93
|
+
<div class="buttons"><button class="btn btn-hot">Save</button></div></form>`, '', 'workspace-list')}
|
|
94
|
+
${region('Installation', html`<p class="muted u-mt0">Set in the server's environment by its administrators (<code>PGKILN_REST_ALLOWED_HOSTS</code>).</p>
|
|
95
|
+
${inst.allowed.includes('*') ? html`<p>Any public host (<code>*</code>).</p>` : entries(inst.allowed)}
|
|
96
|
+
${m.admin ? html`<h3 class="u-mt1">Private hosts</h3><p class="muted small">Hosts on private or loopback addresses that may be called (<code>PGKILN_REST_PRIVATE_HOSTS</code>).</p>${entries(inst.private)}` : ''}`)}
|
|
97
|
+
</div>
|
|
98
|
+
${region('Check a URL', html`<form method="get" action="${BASE}/network" class="search u-mwnone">
|
|
99
|
+
<input type="hidden" name="ws" value="${w.id}">
|
|
100
|
+
${input('url', 'URL', tryUrl, { type: 'url', placeholder: 'https://api.example.com/v1/items' })}
|
|
101
|
+
<div class="buttons"><button class="btn">${icon('search')} Check</button></div></form>
|
|
102
|
+
<div role="status">${verdict ? html`<div class="alert ${verdict.ok ? 'alert-success' : 'alert-error'}">${verdict.text}</div>` : ''}</div>
|
|
103
|
+
<p class="muted small">Nothing is called: this applies the lists to the URL as they are now.</p>`)}
|
|
104
|
+
${m.admin ? region('Workspaces', html`<div class="table-wrap"><table class="report report-reflow">
|
|
105
|
+
<thead><tr><th>Workspace</th><th>Outgoing calls</th></tr></thead>
|
|
106
|
+
<tbody>${all.map((x) => html`<tr><td data-label="Workspace"><a href="${BASE}/network?ws=${x.id}">${x.name}</a></td>
|
|
107
|
+
<td data-label="Outgoing calls">${x.network_restricted ? html`<span class="tag">${icon('lock')} ${x.n} host${x.n === 1 ? '' : 's'}</span>` : html`<span class="muted">the installation's list</span>`}</td></tr>`)}</tbody></table></div>`) : ''}
|
|
108
|
+
${region('Refused calls', html`<p class="muted u-mt0">The last 100 calls of this workspace that a list refused (kept 7 days). The application's debug log has them too.</p>
|
|
109
|
+
<div class="table-wrap"><table class="report report-reflow">
|
|
110
|
+
<thead><tr><th>When (UTC)</th><th>Application</th><th>Call</th><th>Host</th><th>Refused by</th></tr></thead>
|
|
111
|
+
<tbody>${refusals.length ? refusals.map((r) => html`<tr><td data-label="When">${when(r.at)}</td><td data-label="Application">${r.alias ?? html`<span class="muted">–</span>`}</td>
|
|
112
|
+
<td data-label="Call" class="u-break">${r.what}<br><span class="muted small">${r.url ?? ''}</span></td>
|
|
113
|
+
<td data-label="Host" class="u-break"><code>${r.host}</code></td>
|
|
114
|
+
<td data-label="Refused by">${r.list === 'workspace' ? 'the workspace\'s list' : 'the installation\'s list'}</td></tr>`)
|
|
115
|
+
: html`<tr><td colspan="5" class="empty">No refused calls.</td></tr>`}</tbody></table></div>`, '', 'refusals')}`;
|
|
116
|
+
return send(reply, s, shell(s, 'Network access', [['Administration'], ['Network access']], main, 'users'));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
app.post(`${BASE}/network`, async (req: Req, reply) => {
|
|
120
|
+
const s = await developer(req, reply);
|
|
121
|
+
if (!s) return;
|
|
122
|
+
const m = await manageable(s, req.body?.ws);
|
|
123
|
+
if (!m) return refuse(s, reply);
|
|
124
|
+
const target = `${BASE}/network?ws=${m.workspace}`;
|
|
125
|
+
try {
|
|
126
|
+
const hosts = parseHostList(String(req.body?.hosts ?? ''));
|
|
127
|
+
const restricted = req.body?.restricted === 'true';
|
|
128
|
+
const r = await owner.query('update meta.workspace set network_restricted = $2, network_hosts = $3 where id = $1', [m.workspace, restricted, hosts]);
|
|
129
|
+
if (!r.rowCount) return reply.code(404).send('Not found');
|
|
130
|
+
// cached REST responses were fetched under the old list
|
|
131
|
+
clearResponseCache();
|
|
132
|
+
await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req),
|
|
133
|
+
detail: `network access of workspace ${m.workspace}: ${restricted ? `only ${hosts.join(', ') || 'no hosts'}` : 'the installation\'s list'}`.slice(0, 1000) });
|
|
134
|
+
flash(s, restricted ? `Saved: this workspace's applications call ${hosts.length ? 'only these hosts' : 'no hosts at all'}.` : 'Saved: the installation\'s list applies.');
|
|
135
|
+
} catch (e) {
|
|
136
|
+
flash(s, (e as Error).message, 'error');
|
|
137
|
+
}
|
|
138
|
+
return back(reply, s, target);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -30,6 +30,7 @@ export function objectMenu(schema: string, o: MenuObj): Raw {
|
|
|
30
30
|
${o.type === 'policy' ? '' : html`<a href="${open}" data-obj-open="${`${BASE}/sql/objects/panel?${new URLSearchParams({ schema, k: o.type, o: o.ref })}`}">${icon('eye')} Open</a>`}
|
|
31
31
|
${table ? item(`${BASE}/sql/objects/table?${new URLSearchParams({ schema, o: o.ref })}`, 'edit', 'Edit…') : ''}
|
|
32
32
|
${table ? item(`${BASE}/sql/objects/table?${new URLSearchParams({ schema, template: o.ref })}`, 'copy', 'Use as template') : ''}
|
|
33
|
+
${table ? item(`${BASE}/sql/objects/security?${new URLSearchParams({ o: o.ref })}`, 'shield', 'Security…') : ''}
|
|
33
34
|
${table ? item(`${BASE}/sql/load`, 'upload', 'Load data') : ''}
|
|
34
35
|
${table ? html`${item(q('index'), 'list', 'Add index')}${item(q('trigger'), 'bolt', 'Add trigger')}${item(q('policy'), 'shield', 'Add policy')}` : ''}
|
|
35
36
|
${RELATIONS.has(o.type) ? item(`${BASE}/sql/rest/object?${new URLSearchParams({ schema, name: o.name })}`, 'globe', 'REST › Enable / Disable') : ''}
|
|
@@ -422,5 +422,5 @@ export async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s
|
|
|
422
422
|
body = await ddlOf((x) => (x.kind === 'table' || x.kind === 'view' || x.kind === 'materialized view') ? x.name === relname : x.table === relname);
|
|
423
423
|
}
|
|
424
424
|
return html`<h2 class="ob-title">${icon(OB_TYPES.find(([t]) => t === o.type)![2])} ${o.ref}${o.rls ? html` <span class="tag">RLS</span>` : ''}${o.type === 'table'
|
|
425
|
-
? html` <a class="btn btn-sm" href="${BASE}/sql/objects/table?${new URLSearchParams({ schema, o: o.ref })}">${icon('edit')} Edit table</a>` : ''}</h2>${nav}${region(tabs.find(([k]) => k === tab)![1], body)}`;
|
|
425
|
+
? html` <a class="btn btn-sm" href="${BASE}/sql/objects/table?${new URLSearchParams({ schema, o: o.ref })}">${icon('edit')} Edit table</a> <a class="btn btn-sm" href="${BASE}/sql/objects/security?${new URLSearchParams({ o: o.ref })}">${icon('shield')} Security</a>` : ''}</h2>${nav}${region(tabs.find(([k]) => k === tab)![1], body)}`;
|
|
426
426
|
}
|
|
@@ -12,6 +12,7 @@ import { webRequest } from '../webclient.ts';
|
|
|
12
12
|
import { sourceProblems } from '../websources.ts';
|
|
13
13
|
import { back, BASE, csrf, developer, flash, input, region, send, shell, type Body, type Req } from './ui.ts';
|
|
14
14
|
import { currentWorkspace } from './workspaces.ts';
|
|
15
|
+
import { scopeForWorkspace } from '../network-acl.ts';
|
|
15
16
|
|
|
16
17
|
// Workspace utilities → REST Source Catalogs (migration 095; APEX: Workspace
|
|
17
18
|
// Utilities → REST Source Catalogs): the current workspace's catalogs, a
|
|
@@ -59,7 +60,7 @@ function importForm(s: Session, action: string, replacing: boolean): Raw {
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
/** The text of an uploaded, pasted or fetched document (and where it came from). */
|
|
62
|
-
async function documentText(req: Req): Promise<{ text: string; from?: string }> {
|
|
63
|
+
async function documentText(req: Req): Promise<{ text: string; from?: string; url?: string }> {
|
|
63
64
|
let text = '';
|
|
64
65
|
if (req.isMultipart()) {
|
|
65
66
|
const parsed = await readMultipart(req, MAX_DOC_BYTES / 1024 / 1024);
|
|
@@ -73,7 +74,19 @@ async function documentText(req: Req): Promise<{ text: string; from?: string }>
|
|
|
73
74
|
const url = String(req.body?.url ?? '').trim();
|
|
74
75
|
if (!url) throw new Error('Choose a file, paste a document or give its URL.');
|
|
75
76
|
if (!/^https?:\/\//i.test(url)) throw new Error('The URL starts with http:// or https://.');
|
|
76
|
-
|
|
77
|
+
return { text: '', url };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The document itself: the uploaded or pasted text, or (sprint 118) the URL read now, after the developer
|
|
82
|
+
* was checked, within the installation's allow-list and the current workspace's network access list.
|
|
83
|
+
*/
|
|
84
|
+
async function documentOf(doc: { text: string; from?: string; url?: string }, s: Session): Promise<{ text: string; from?: string }> {
|
|
85
|
+
if (!doc.url) return doc;
|
|
86
|
+
const r = await webRequest(doc.url, {
|
|
87
|
+
headers: { accept: 'application/json' }, timeoutMs: 15_000, maxBytes: MAX_DOC_BYTES,
|
|
88
|
+
scope: await scopeForWorkspace(currentWorkspace(s), 'REST source catalog (read from a URL)'),
|
|
89
|
+
});
|
|
77
90
|
if (r.status !== 200) throw new Error(`The URL answered HTTP ${r.status}.`);
|
|
78
91
|
return { text: r.body.toString('utf8'), from: r.url };
|
|
79
92
|
}
|
|
@@ -177,7 +190,7 @@ export async function restCatalogRoutes(app: FastifyInstance) {
|
|
|
177
190
|
});
|
|
178
191
|
|
|
179
192
|
app.post(`${root}/import`, async (req: Req, reply) => {
|
|
180
|
-
let doc: { text: string; from?: string } | Error;
|
|
193
|
+
let doc: { text: string; from?: string; url?: string } | Error;
|
|
181
194
|
try {
|
|
182
195
|
doc = await documentText(req);
|
|
183
196
|
} catch (e) {
|
|
@@ -187,6 +200,7 @@ export async function restCatalogRoutes(app: FastifyInstance) {
|
|
|
187
200
|
if (!s) return;
|
|
188
201
|
try {
|
|
189
202
|
if (doc instanceof Error) throw doc;
|
|
203
|
+
doc = await documentOf(doc, s);
|
|
190
204
|
const { catalog, notes } = readCatalogText(doc.text, { baseUrl: req.body?.base_url, from: doc.from, name: req.body?.name });
|
|
191
205
|
const problems = wholeCatalogProblems(catalog);
|
|
192
206
|
if (problems.length) throw new Error(`The catalog can't be used as it is: ${problems.slice(0, 8).join(' ')}${problems.length > 8 ? ` (and ${problems.length - 8} more)` : ''}`);
|
|
@@ -264,7 +278,7 @@ export async function restCatalogRoutes(app: FastifyInstance) {
|
|
|
264
278
|
});
|
|
265
279
|
|
|
266
280
|
app.post(`${root}/:cid(^\\d+$)/import`, async (req: Req, reply) => {
|
|
267
|
-
let doc: { text: string; from?: string } | Error;
|
|
281
|
+
let doc: { text: string; from?: string; url?: string } | Error;
|
|
268
282
|
try {
|
|
269
283
|
doc = await documentText(req);
|
|
270
284
|
} catch (e) {
|
|
@@ -276,6 +290,7 @@ export async function restCatalogRoutes(app: FastifyInstance) {
|
|
|
276
290
|
if (!c) return reply.code(404).send('Not found');
|
|
277
291
|
try {
|
|
278
292
|
if (doc instanceof Error) throw doc;
|
|
293
|
+
doc = await documentOf(doc, s);
|
|
279
294
|
const { catalog, notes } = readCatalogText(doc.text, { baseUrl: req.body?.base_url, from: doc.from, name: c.name });
|
|
280
295
|
const problems = wholeCatalogProblems(catalog);
|
|
281
296
|
if (problems.length) throw new Error(problems.slice(0, 8).join(' '));
|
package/src/builder/sql.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { sqlUtilityRoutes } from './sql-utilities.ts';
|
|
|
19
19
|
import { createTemplate, objectBrowserRoutes } from './object-browser.ts';
|
|
20
20
|
import { objectActionRoutes } from './object-actions.ts';
|
|
21
21
|
import { tableEditorRoutes } from './table-editor.ts';
|
|
22
|
+
import { dataSecurityRoutes } from './data-security.ts';
|
|
22
23
|
import { dataModelerRoutes } from './data-modeler.ts';
|
|
23
24
|
import { autoRestBuilderRoutes } from './autorest.ts';
|
|
24
25
|
import { restModuleRoutes } from './rest-modules.ts';
|
|
@@ -131,6 +132,7 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
131
132
|
await app.register(objectActionRoutes);
|
|
132
133
|
// (sprint 95) Table Properties: create, edit or copy a table in a form
|
|
133
134
|
await app.register(tableEditorRoutes);
|
|
135
|
+
await app.register(dataSecurityRoutes);
|
|
134
136
|
// (sprint 98) the Data Modeler: relational diagrams, their DDL and data dictionary
|
|
135
137
|
await app.register(dataModelerRoutes);
|
|
136
138
|
// (sprint 100) RESTful Services: AutoREST (autorest.ts)
|
|
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
|
|
|
2
2
|
import { owner } from '../db.ts';
|
|
3
3
|
import { html, raw } from '../html.ts';
|
|
4
4
|
import { icon } from '../icons.ts';
|
|
5
|
-
import { appStyles, BASE_STYLES, baseStyleOf, FONT_SIZES, FONTS, HEX, ITEM_REF, MAX_STYLES, parseStyle, RADII, STYLE_NAME, type StyleVariant } from '../runtime/styles.ts';
|
|
5
|
+
import { APPEARANCE_DEFAULT, appStyles, BASE_STYLES, baseStyleOf, FONT_SIZES, FONTS, HEX, ITEM_REF, MAX_STYLES, NAV_APPEARANCES, navAppearance, parseStyle, RADII, STYLE_NAME, type StyleVariant } from '../runtime/styles.ts';
|
|
6
6
|
import { REGION_OPTIONS, BUTTON_OPTIONS } from '../runtime/template-options.ts';
|
|
7
7
|
import { appHeader, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
|
|
8
8
|
|
|
@@ -28,9 +28,17 @@ async function updateTheme(id: string, change: (theme: Theme) => Theme | string)
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
const label = (list: Record<string, { label: string }>, k: string | undefined) => (k && list[k] ? list[k].label : '- as the base -');
|
|
31
|
+
/** (sprint 117) a style's navigation appearance as shown in the list */
|
|
32
|
+
const appearanceLabel = (k: string | undefined) => (k === APPEARANCE_DEFAULT ? 'Default' : k && NAV_APPEARANCES[k] ? NAV_APPEARANCES[k].label : '- as the application -');
|
|
33
|
+
const appearanceChoices = (forStyle: boolean): [string, string][] => [
|
|
34
|
+
forStyle ? ['', '- as the application -'] : ['', 'Default (the base style\'s header and menu)'],
|
|
35
|
+
...(forStyle ? [[APPEARANCE_DEFAULT, 'Default (the base style\'s header and menu)'] as [string, string]] : []),
|
|
36
|
+
...Object.entries(NAV_APPEARANCES).map(([k, v]): [string, string] => [k, v.label]),
|
|
37
|
+
];
|
|
38
|
+
const APPEARANCE_HELP = 'APEX 26.2: Pillar Light puts the header and menu on the page\'s own light surfaces, Pillar Dark makes them one dark column in the header colour; both have a header pillar strip and show the current page as a filled pill.';
|
|
31
39
|
|
|
32
40
|
function styleForm(appId: number, s: Parameters<typeof csrf>[0], st: StyleVariant | null, base: Theme) {
|
|
33
|
-
const colour = (name: 'accent' | 'header' | 'accent_dark' | 'header_dark', title: string, fallback: string) => {
|
|
41
|
+
const colour = (name: 'accent' | 'header' | 'accent_dark' | 'header_dark' | 'strip' | 'strip_dark', title: string, fallback: string) => {
|
|
34
42
|
const ref = st?.[name] && ITEM_REF.test(st[name]!) ? st[name]! : '';
|
|
35
43
|
const own = !!st?.[name] && !ref;
|
|
36
44
|
const value = (own ? st?.[name] : undefined) ?? (typeof base[name] === 'string' && HEX.test(base[name] as string) ? (base[name] as string) : fallback);
|
|
@@ -49,14 +57,22 @@ function styleForm(appId: number, s: Parameters<typeof csrf>[0], st: StyleVarian
|
|
|
49
57
|
sizes: Object.fromEntries(Object.entries(FONT_SIZES).map(([k, v]) => [k, v.css])),
|
|
50
58
|
radii: Object.fromEntries(Object.entries(RADII).map(([k, v]) => [k, v.css])),
|
|
51
59
|
});
|
|
52
|
-
|
|
60
|
+
// (sprint 117) the preview has a menu: the current page, its parent and the appearance in use
|
|
61
|
+
const appearance = navAppearance(base as never, st) ?? '';
|
|
62
|
+
const preview = html`<div class="tr-preview tr-preview-${baseStyleOf(base)}" data-tr-preview="${lists}" data-tr-app-appearance="${navAppearance(base as never, null) ?? ''}" data-appearance="${appearance}" aria-label="Preview of the style">
|
|
53
63
|
<div class="tr-preview-header">${icon('menu')}<span>Preview</span></div>
|
|
64
|
+
<div class="tr-preview-main">
|
|
65
|
+
<ul class="tr-preview-nav" aria-label="Sample menu">
|
|
66
|
+
<li><span class="tr-nav-parent">${icon('users')}<span>Employees</span></span>
|
|
67
|
+
<ul><li><span class="tr-nav-current">Employee list</span></li><li><span>Directory</span></li></ul></li>
|
|
68
|
+
<li><span>${icon('chart')}<span>Reports</span></span></li>
|
|
69
|
+
</ul>
|
|
54
70
|
<div class="tr-preview-body">
|
|
55
71
|
<section class="region region-standard"><header class="region-header"><h2>A region</h2></header>
|
|
56
72
|
<div class="region-body"><p class="u-mt0">Text with a <a href="#tr-preview-link" id="tr-preview-link">link</a> and a <span class="tc-badge tc-badge-info">badge</span>.</p>
|
|
57
73
|
<div class="field"><label class="label" for="tr-preview-input">A field</label><input id="tr-preview-input" value="Value" readonly></div>
|
|
58
74
|
<div class="buttons"><button type="button" class="btn">Cancel</button><button type="button" class="btn btn-hot">Save</button></div></div></section>
|
|
59
|
-
</div></div>`;
|
|
75
|
+
</div></div></div>`;
|
|
60
76
|
return html`${preview}<form method="post" action="${BASE}/apps/${appId}/theme/styles" data-tr-form>${csrf(s)}
|
|
61
77
|
${st ? html`<input type="hidden" name="original" value="${st.name}">` : ''}
|
|
62
78
|
<div class="form-grid">
|
|
@@ -65,6 +81,9 @@ function styleForm(appId: number, s: Parameters<typeof csrf>[0], st: StyleVarian
|
|
|
65
81
|
${colour('header', 'Header colour', baseOf.header)}
|
|
66
82
|
${colour('accent_dark', 'Accent colour in dark mode', '#a59cff')}
|
|
67
83
|
${colour('header_dark', 'Header colour in dark mode', '#0d0c1a')}
|
|
84
|
+
${select('appearance', 'Navigation appearance', st?.appearance ?? '', appearanceChoices(true), APPEARANCE_HELP)}
|
|
85
|
+
${colour('strip', 'Header strip colour', baseOf.accent)}
|
|
86
|
+
${colour('strip_dark', 'Header strip colour in dark mode', '#a59cff')}
|
|
68
87
|
${select('font', 'Font', st?.font ?? '', choices(FONTS))}
|
|
69
88
|
${select('font_size', 'Font size', st?.font_size ?? '', choices(FONT_SIZES))}
|
|
70
89
|
${select('radius', 'Corners', st?.radius ?? '', choices(RADII))}
|
|
@@ -86,6 +105,13 @@ export async function themeRollerRoutes(app: FastifyInstance) {
|
|
|
86
105
|
const theme: Theme = a.theme ?? {};
|
|
87
106
|
const styles = appStyles(a.theme);
|
|
88
107
|
const editing = typeof req.query.edit === 'string' ? styles.find((x) => x.name === req.query.edit) ?? null : null;
|
|
108
|
+
// (sprint 117) the application's header strip colours (else the accent)
|
|
109
|
+
const appColour = (k: 'strip' | 'strip_dark', title: string, fallback: string) => {
|
|
110
|
+
const v = typeof theme[k] === 'string' && HEX.test(theme[k] as string) ? (theme[k] as string) : '';
|
|
111
|
+
return html`<div class="field"><label class="label" for="f_app_${k}">${title}</label>
|
|
112
|
+
<input id="f_app_${k}" name="${k}" type="color" value="${v || fallback}">
|
|
113
|
+
<label class="check"><input type="checkbox" name="${k}_own" value="true"${v ? raw(' checked') : ''}> Use this colour (else the accent colour)</label></div>`;
|
|
114
|
+
};
|
|
89
115
|
const swatch = (hex: string | undefined) =>
|
|
90
116
|
!hex ? html`<span class="muted">base</span>` : ITEM_REF.test(hex) ? html`<code>${hex}</code>` : html`<input type="color" value="${hex}" disabled aria-label="${hex}" class="swatch"> <code>${hex}</code>`;
|
|
91
117
|
const main = html`${appHeader(a, 'settings')}
|
|
@@ -96,9 +122,9 @@ export async function themeRollerRoutes(app: FastifyInstance) {
|
|
|
96
122
|
are what users see without a style; each style below changes the colours and may set a font, font size and corners.
|
|
97
123
|
The colours apply to the light theme, the dark-mode colours to the dark theme (pick light colours there); fonts, sizes and corners to both.</p>
|
|
98
124
|
${styles.length
|
|
99
|
-
? html`<div class="table-wrap"><table class="report"><thead><tr><th>Name</th><th>Accent</th><th>Header</th><th>Dark accent</th><th>Dark header</th><th>Font</th><th>Font size</th><th>Corners</th><th><span class="sr-only">Actions</span></th></tr></thead><tbody>
|
|
125
|
+
? html`<div class="table-wrap"><table class="report"><thead><tr><th>Name</th><th>Accent</th><th>Header</th><th>Dark accent</th><th>Dark header</th><th>Navigation</th><th>Strip</th><th>Font</th><th>Font size</th><th>Corners</th><th><span class="sr-only">Actions</span></th></tr></thead><tbody>
|
|
100
126
|
${styles.map((x) => html`<tr><td>${x.name}${theme.style === x.name ? html` <span class="badge">default</span>` : ''}${x.condition ? html`<br><small class="muted">when <code>${x.condition.length > 60 ? `${x.condition.slice(0, 60)}…` : x.condition}</code></small>` : ''}</td>
|
|
101
|
-
<td>${swatch(x.accent)}</td><td>${swatch(x.header)}</td><td>${swatch(x.accent_dark)}</td><td>${swatch(x.header_dark)}</td><td>${label(FONTS, x.font)}</td><td>${label(FONT_SIZES, x.font_size)}</td><td>${label(RADII, x.radius)}</td>
|
|
127
|
+
<td>${swatch(x.accent)}</td><td>${swatch(x.header)}</td><td>${swatch(x.accent_dark)}</td><td>${swatch(x.header_dark)}</td><td>${appearanceLabel(x.appearance)}</td><td>${swatch(x.strip)}</td><td>${label(FONTS, x.font)}</td><td>${label(FONT_SIZES, x.font_size)}</td><td>${label(RADII, x.radius)}</td>
|
|
102
128
|
<td><a class="btn btn-sm" href="${BASE}/apps/${a.id}/theme?edit=${encodeURIComponent(x.name)}">${icon('edit')} Edit</a>
|
|
103
129
|
<form method="post" action="${BASE}/apps/${a.id}/theme/styles/delete" class="u-inline">${csrf(s)}<input type="hidden" name="name" value="${x.name}">
|
|
104
130
|
<button class="btn btn-sm btn-danger" data-confirm="Delete style ${x.name}?">Delete</button></form></td></tr>`)}
|
|
@@ -107,6 +133,9 @@ export async function themeRollerRoutes(app: FastifyInstance) {
|
|
|
107
133
|
<form method="post" action="${BASE}/apps/${a.id}/theme/settings" class="u-mt1">${csrf(s)}
|
|
108
134
|
<div class="form-grid">
|
|
109
135
|
${select('style', 'Default style', typeof theme.style === 'string' ? theme.style : '', [['', 'None (the base style and colours)'], ...styles.map((x): [string, string] => [x.name, x.name])], 'What everyone sees unless they chose another style.')}
|
|
136
|
+
${select('appearance', 'Navigation appearance', navAppearance(theme as never, null) ?? '', appearanceChoices(false), `${APPEARANCE_HELP} A style may choose its own.`)}
|
|
137
|
+
${appColour('strip', 'Header strip colour', BASE_STYLES[baseStyleOf(a.theme)].accent)}
|
|
138
|
+
${appColour('strip_dark', 'Header strip colour in dark mode', '#a59cff')}
|
|
110
139
|
</div>
|
|
111
140
|
<div class="field"><label class="check"><input type="checkbox" name="style_choice" value="true"${theme.style_choice === true ? raw(' checked') : ''}> Users may choose a style</label>
|
|
112
141
|
<small class="help">Adds the styles to the user menu and My account (APEX: "Enable End Users to Choose Theme Style"). A user's choice is kept per application on the account; only this application's styles can be chosen.</small></div>
|
|
@@ -138,6 +167,9 @@ export async function themeRollerRoutes(app: FastifyInstance) {
|
|
|
138
167
|
header: b.header_item?.trim() || (b.header_own === 'true' ? b.header : undefined),
|
|
139
168
|
accent_dark: b.accent_dark_item?.trim() || (b.accent_dark_own === 'true' ? b.accent_dark : undefined),
|
|
140
169
|
header_dark: b.header_dark_item?.trim() || (b.header_dark_own === 'true' ? b.header_dark : undefined),
|
|
170
|
+
strip: b.strip_item?.trim() || (b.strip_own === 'true' ? b.strip : undefined),
|
|
171
|
+
strip_dark: b.strip_dark_item?.trim() || (b.strip_dark_own === 'true' ? b.strip_dark : undefined),
|
|
172
|
+
appearance: b.appearance,
|
|
141
173
|
condition: b.condition,
|
|
142
174
|
});
|
|
143
175
|
if (typeof parsed === 'string') {
|
|
@@ -196,6 +228,15 @@ export async function themeRollerRoutes(app: FastifyInstance) {
|
|
|
196
228
|
const problem = await updateTheme(id, (t) => {
|
|
197
229
|
const styles = appStyles(t as never);
|
|
198
230
|
const next: Theme = { ...t, style_choice: b.style_choice === 'true' };
|
|
231
|
+
// (sprint 117) the navigation appearance and the header strip colours: fixed keys and #rrggbb only
|
|
232
|
+
if (!b.appearance) delete next.appearance;
|
|
233
|
+
else if (Object.hasOwn(NAV_APPEARANCES, b.appearance)) next.appearance = b.appearance;
|
|
234
|
+
else return 'Navigation appearance: choose Default, Pillar Light or Pillar Dark.';
|
|
235
|
+
for (const k of ['strip', 'strip_dark'] as const) {
|
|
236
|
+
if (b[`${k}_own`] !== 'true') delete next[k];
|
|
237
|
+
else if (HEX.test(b[k] ?? '')) next[k] = b[k]!.toLowerCase();
|
|
238
|
+
else return 'Header strip colour: #rrggbb.';
|
|
239
|
+
}
|
|
199
240
|
if (!b.style) delete next.style;
|
|
200
241
|
else if (styles.some((x) => x.name === b.style)) next.style = b.style;
|
|
201
242
|
else return 'Choose one of the styles.';
|
package/src/builder/ui.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { documentShell } from '../layout.ts';
|
|
|
9
9
|
import { getSession, saveState, takeFlash, type Session } from '../session.ts';
|
|
10
10
|
import { SQL_TOOLS, TOOL_GROUPS } from './sql-tools.ts';
|
|
11
11
|
import { ensureDevbar } from '../runtime/devbar.ts';
|
|
12
|
+
import { noteChange } from './app-updates.ts';
|
|
12
13
|
|
|
13
14
|
// Shared building blocks of the builder UI.
|
|
14
15
|
|
|
@@ -139,6 +140,8 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
|
|
|
139
140
|
<a href="${BASE}/workspaces/${s.workspace.id}#preferences">${icon('sliders-horizontal')} Workspace preferences</a>
|
|
140
141
|
<a href="${BASE}/workspaces/${s.workspace.id}#links">${icon('external-link')} Extension links</a>
|
|
141
142
|
<a href="${BASE}/workspaces/${s.workspace.id}/utilization">${icon('chart')} Workspace utilization</a>
|
|
143
|
+
<a href="${BASE}/mail-queue">${icon('inbox')} Mail queue</a>
|
|
144
|
+
<a href="${BASE}/network">${icon('globe')} Network access</a>
|
|
142
145
|
<a href="${BASE}/workspaces/${s.workspace.id}">${icon('layers')} Workspace and developers</a>
|
|
143
146
|
</div>` : ''}
|
|
144
147
|
<div class="menu-section menu-links">
|
|
@@ -326,6 +329,8 @@ export async function developer(req: Req, reply: FastifyReply) {
|
|
|
326
329
|
await refuseLocked(req, reply, s, hit);
|
|
327
330
|
return null;
|
|
328
331
|
}
|
|
332
|
+
// (116) who changed the application last (app-updates.ts)
|
|
333
|
+
noteChange(req, s as Session & { username: string }, target?.appId ?? null);
|
|
329
334
|
}
|
|
330
335
|
return s;
|
|
331
336
|
}
|
|
@@ -276,7 +276,7 @@ export async function webSourceRoutes(app: FastifyInstance) {
|
|
|
276
276
|
for (const p of src.params) values[p.name] = String(req.body?.[`p_${p.name}`] ?? '');
|
|
277
277
|
const r = buildRequest(src, values);
|
|
278
278
|
const credential = src.credential ? await loadCredential(src.app_id, src.credential) : null;
|
|
279
|
-
const res = await call({ ...r, credential, timeoutMs: src.timeout_s * 1000 });
|
|
279
|
+
const res = await call({ ...r, appId: src.app_id, what: `REST data source ${src.name} (builder test)`, credential, timeoutMs: src.timeout_s * 1000 });
|
|
280
280
|
const text = res.body.toString('utf8');
|
|
281
281
|
result.excerpt = text.length > 3000 ? `${text.slice(0, 3000)}…` : text;
|
|
282
282
|
if (res.status < 200 || res.status > 299) throw new Error(`The web service answered ${res.status}.`);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
|
+
import { owner } from '../db.ts';
|
|
3
|
+
import { html } from '../html.ts';
|
|
4
|
+
import type { MailService } from '../mail/mail.ts';
|
|
5
|
+
import { PROVIDER_LABELS, PROVIDERS, type Provider } from '../mail/providers.ts';
|
|
6
|
+
import { encryptSecret } from '../secrets.ts';
|
|
7
|
+
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
8
|
+
import { formValues, parseForm, PROVIDER_TEXT, runCheck, serviceForm } from './mail.ts';
|
|
9
|
+
import { back, BASE, csrf, developer, flash, region, select, send, shell, type Req } from './ui.ts';
|
|
10
|
+
import { workspaceAdmin } from './workspaces.ts';
|
|
11
|
+
|
|
12
|
+
// (sprint 118, WS-9; APEX 26.1 workspace-level SMTP credentials) A workspace's
|
|
13
|
+
// own mail service, managed by its administrators on /builder/workspaces/:id/mail:
|
|
14
|
+
// a meta.mail_service row with workspace_id (one per workspace), named
|
|
15
|
+
// WORKSPACE_<id>. Its applications that an administrator gave no service send
|
|
16
|
+
// through it. The secret is encrypted with PGKILN_SECRET_KEY like a web
|
|
17
|
+
// credential's, write-only (never shown, never exported). It has no base URL
|
|
18
|
+
// or token URL (the providers' own APIs only), and its SMTP host must be on the
|
|
19
|
+
// installation's allow-list and the workspace's network access list
|
|
20
|
+
// (src/mail/mail.ts serviceSettings, checked again on every send).
|
|
21
|
+
|
|
22
|
+
type Service = MailService & { has_secret: boolean };
|
|
23
|
+
|
|
24
|
+
async function wsAdminOnly(s: Session, reply: FastifyReply, wsId: string) {
|
|
25
|
+
if (await workspaceAdmin(s.username, wsId)) return true;
|
|
26
|
+
const main = html`<h1 class="u-mb1">Workspace mail service</h1><div class="alert alert-error" role="alert">Only the workspace's administrators manage its mail service.</div>`;
|
|
27
|
+
await send(reply.code(403), s, shell(s, 'Workspace mail service', [['Administration'], ['Workspace mail service']], main, 'users'));
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const serviceOf = (wsId: number | string) =>
|
|
32
|
+
owner.one<Service>('select *, secret_enc is not null as has_secret from meta.mail_service where workspace_id = $1', [wsId]);
|
|
33
|
+
|
|
34
|
+
export async function workspaceMailRoutes(app: FastifyInstance) {
|
|
35
|
+
const page = async (s: Session, req: Req, reply: FastifyReply, tried: { ok: boolean; text: string; to?: string; from?: string } | null = null, typed?: ReturnType<typeof parseForm>) => {
|
|
36
|
+
const w = await owner.one<{ id: number; name: string }>('select id, name from meta.workspace where id = $1', [req.params.id]);
|
|
37
|
+
if (!w) return reply.code(404).send('Not found');
|
|
38
|
+
const x = await serviceOf(w.id);
|
|
39
|
+
const wanted = String(req.query.provider ?? typed?.provider ?? '');
|
|
40
|
+
const provider: Provider = (PROVIDERS as readonly string[]).includes(wanted) ? (wanted as Provider) : x?.provider ?? 'smtp';
|
|
41
|
+
const users = (await owner.query<{ alias: string; own: string | null }>(
|
|
42
|
+
`select a.alias, (select s.name from meta.app_mail_service m join meta.mail_service s on s.id = m.service_id where m.app_id = a.id) as own
|
|
43
|
+
from meta.app a where meta.app_workspace(a.id) = $1 order by a.alias`, [w.id])).rows;
|
|
44
|
+
const shown = typed ? { ...x, description: typed.description, provider: typed.provider, settings: typed.settings, senders: typed.senders, enabled: typed.enabled } : x ?? {};
|
|
45
|
+
const main = html`
|
|
46
|
+
<div class="title-row"><h1>Mail service · ${w.name}</h1></div>
|
|
47
|
+
<p class="muted u-mt0">This workspace's own mail service: its applications that an administrator gave no service of their own send through it (<code>meta.send_mail()</code>, the Send e-mail process). The secret is stored encrypted and never shown again. It uses the provider's own API; an SMTP server must be on the installation's allow-list and this workspace's <a href="${BASE}/network">network access</a> list.</p>
|
|
48
|
+
<div class="mail-form-page">
|
|
49
|
+
${region('Settings', html`
|
|
50
|
+
<form method="get" class="provider-switch">${select('provider', 'Provider', provider, PROVIDERS.map((p): [string, string] => [p, PROVIDER_LABELS[p]]),
|
|
51
|
+
x && provider !== x.provider ? 'Not saved yet: fill in the settings below and save to switch.' : PROVIDER_TEXT[provider][1])}<button class="btn btn-sm">Change</button></form>
|
|
52
|
+
${serviceForm(shown, provider, `${BASE}/workspaces/${w.id}/mail`, csrf(s), !x, tried, { id: w.id })}
|
|
53
|
+
${x ? html`<form method="post" action="${BASE}/workspaces/${w.id}/mail/delete" class="danger-zone">${csrf(s)}
|
|
54
|
+
<button class="btn btn-danger" data-confirm="Delete this workspace's mail service? Its applications without a service of their own can't send mail then.">Delete service</button></form>` : ''}`)}
|
|
55
|
+
</div>
|
|
56
|
+
${region('Applications', html`<div class="table-wrap"><table class="report report-reflow">
|
|
57
|
+
<thead><tr><th>Application</th><th>Sends through</th></tr></thead>
|
|
58
|
+
<tbody>${users.length ? users.map((a) => html`<tr><td data-label="Application">${a.alias}</td>
|
|
59
|
+
<td data-label="Sends through">${a.own ? html`${a.own} <span class="muted">(chosen by an administrator)</span>` : x ? 'this service' : html`<span class="muted">nothing yet</span>`}</td></tr>`)
|
|
60
|
+
: html`<tr><td colspan="2" class="empty">No applications.</td></tr>`}</tbody></table></div>`)}`;
|
|
61
|
+
return send(reply, s, shell(s, `Mail service · ${w.name}`, [['Administration'], [w.name, `${BASE}/workspaces/${w.id}`], ['Mail service']], main, 'users'));
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
app.get(`${BASE}/workspaces/:id(^\\d{1,9}$)/mail`, async (req: Req, reply) => {
|
|
65
|
+
const s = await developer(req, reply);
|
|
66
|
+
if (!s || !(await wsAdminOnly(s, reply, req.params.id))) return;
|
|
67
|
+
return page(s, req, reply);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/mail`, async (req: Req, reply) => {
|
|
71
|
+
const s = await developer(req, reply);
|
|
72
|
+
if (!s || !(await wsAdminOnly(s, reply, req.params.id))) return;
|
|
73
|
+
const w = await owner.one<{ id: number }>('select id from meta.workspace where id = $1', [req.params.id]);
|
|
74
|
+
if (!w) return reply.code(404).send('Not found');
|
|
75
|
+
const b = (req.body ?? {}) as Record<string, string | undefined>;
|
|
76
|
+
try {
|
|
77
|
+
const old = await serviceOf(w.id);
|
|
78
|
+
const v = formValues(b, !!old?.has_secret && b.remove_secret !== 'true' && old.provider === parseForm(b).provider, { workspace: true });
|
|
79
|
+
const secret = b.secret ? encryptSecret(b.secret) : null;
|
|
80
|
+
if (old)
|
|
81
|
+
await owner.query(
|
|
82
|
+
`update meta.mail_service set description = $2, provider = $3, settings = $4, senders = $5, enabled = $6, updated_at = now(),
|
|
83
|
+
secret_enc = case when $8 then null when $7::text is null then secret_enc else $7 end
|
|
84
|
+
where id = $1 and workspace_id = $9`,
|
|
85
|
+
[old.id, v.description, v.provider, JSON.stringify(v.settings), v.senders, v.enabled, secret, b.remove_secret === 'true' && !b.secret, w.id]);
|
|
86
|
+
else
|
|
87
|
+
await owner.query(
|
|
88
|
+
`insert into meta.mail_service (name, description, provider, settings, senders, enabled, secret_enc, workspace_id) values ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
89
|
+
[`WORKSPACE_${w.id}`, v.description, v.provider, JSON.stringify(v.settings), v.senders, v.enabled, secret, w.id]);
|
|
90
|
+
await logActivity({ appId: null, username: s.username, event: 'mail_service', ip: clientIp(req), detail: `${old ? 'changed' : 'added'} the mail service of workspace ${w.id} (${v.provider})` });
|
|
91
|
+
flash(s, old ? 'Mail service saved.' : 'Mail service added: send a test mail to check it.');
|
|
92
|
+
} catch (e) {
|
|
93
|
+
flash(s, (e as Error).message.includes('mail_service_name_key') ? `A service named WORKSPACE_${w.id} exists already: an administrator renames it first.` : (e as Error).message, 'error');
|
|
94
|
+
}
|
|
95
|
+
return back(reply, s, `${BASE}/workspaces/${w.id}/mail`);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Test connection / Send test mail, through the same checks as sending (JSON for builder.js; else the page again)
|
|
99
|
+
app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/mail/check`, async (req: Req, reply) => {
|
|
100
|
+
const s = await developer(req, reply);
|
|
101
|
+
if (!s || !(await wsAdminOnly(s, reply, req.params.id))) return;
|
|
102
|
+
const w = await owner.one<{ id: number }>('select id from meta.workspace where id = $1', [req.params.id]);
|
|
103
|
+
if (!w) return reply.code(404).send('Not found');
|
|
104
|
+
const b = (req.body ?? {}) as Record<string, string | undefined>;
|
|
105
|
+
// only this workspace's stored secret (never another service's, whatever service_id says)
|
|
106
|
+
const stored = await serviceOf(w.id);
|
|
107
|
+
const result = await runCheck(b, stored ?? null, s.username, w.id);
|
|
108
|
+
await logActivity({ username: s.username, event: 'mail_check', ip: clientIp(req), detail: `${b.check === 'send' ? 'send' : 'connection'} workspace ${w.id}: ${result.ok ? 'ok' : 'failed'}` });
|
|
109
|
+
if (String(req.headers.accept ?? '').includes('application/json')) return reply.header('cache-control', 'no-store').send(result);
|
|
110
|
+
return page(s, req, reply, { ...result, to: String(b.test_to ?? '').trim(), from: String(b.test_from ?? '').trim() }, parseForm(b));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/mail/delete`, async (req: Req, reply) => {
|
|
114
|
+
const s = await developer(req, reply);
|
|
115
|
+
if (!s || !(await wsAdminOnly(s, reply, req.params.id))) return;
|
|
116
|
+
const r = await owner.query('delete from meta.mail_service where workspace_id = $1', [req.params.id]);
|
|
117
|
+
await logActivity({ appId: null, username: s.username, event: 'mail_service', ip: clientIp(req), detail: `deleted the mail service of workspace ${req.params.id}` });
|
|
118
|
+
flash(s, r.rowCount ? 'Mail service deleted.' : 'There was no mail service.');
|
|
119
|
+
return back(reply, s, `${BASE}/workspaces/${req.params.id}/mail`);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -356,6 +356,7 @@ export async function workspaceRoutes(app: FastifyInstance) {
|
|
|
356
356
|
<div class="buttons"><button class="btn">Save developers</button></div></form>`)}
|
|
357
357
|
</div>
|
|
358
358
|
${serviceRegion(s, w)}
|
|
359
|
+
${region('Mail and network access', html`<p class="u-mt0">This workspace's own <a href="${BASE}/workspaces/${w.id}/mail">mail service</a> for its applications without one of their own, its <a href="${BASE}/network?ws=${w.id}">network access</a> list (which hosts its applications may call, within the installation's list), and the <a href="${BASE}/mail-queue${full ? `?ws=${w.id}` : ''}">mail queue</a>.</p>`, '', 'mail-network')}
|
|
359
360
|
<div class="columns">${preferencesRegion(s, w)}${linksRegion(s, w, links)}</div>
|
|
360
361
|
${full ? isolationRegion(s, w, roles.rows) : region('Isolation', html`<p class="u-mt0">${w.isolated ? 'Isolated: its applications and SQL run on database logins of its own.' : 'Shared.'} Administrators change it.</p>`)}
|
|
361
362
|
${!full ? '' : region('Applications', html`<div class="table-wrap"><table class="report report-reflow">
|
package/src/cli/main.ts
CHANGED
|
@@ -212,7 +212,7 @@ const COMMANDS: Record<string, Command> = {
|
|
|
212
212
|
},
|
|
213
213
|
|
|
214
214
|
import: {
|
|
215
|
-
usage: 'pgkiln import <path> [--alias <alias>] [--replace] [--check [--force] | --dry-run]',
|
|
215
|
+
usage: 'pgkiln import <path> [--alias <alias>] [--replace [--only <file>]…] [--check [--force] | --dry-run]',
|
|
216
216
|
summary: 'import a JSON export, an application directory or a .zip of one',
|
|
217
217
|
details:
|
|
218
218
|
'Without --replace the alias must be free. With --replace an existing application with that\n' +
|
|
@@ -220,21 +220,43 @@ const COMMANDS: Record<string, Command> = {
|
|
|
220
220
|
'reports, running tasks and workflows, and the on/off switch of its automations; its pages,\n' +
|
|
221
221
|
'shared components and settings become those of the file. Without an existing application\n' +
|
|
222
222
|
'--replace simply imports.\n\n' +
|
|
223
|
+
'With --replace, --only imports just the named files of a directory export (or .zip): a page\n' +
|
|
224
|
+
'(--only pages/12) or a shared component file or folder (--only shared/lovs/jobs.yaml); the rest\n' +
|
|
225
|
+
'of the application stays as it is in the database. Repeat --only for several.\n\n' +
|
|
223
226
|
'With --check the Advisor checks the imported application before anything is committed: every\n' +
|
|
224
227
|
'SQL fragment is planned as the application\'s role (unknown tables, columns, functions, types and\n' +
|
|
225
228
|
'grants), and references to pages, items, lists of values and schemes are checked. Errors refuse\n' +
|
|
226
229
|
'the import (exit code 1, nothing changes); --force imports anyway. --dry-run checks and never commits.',
|
|
227
|
-
options: { alias: { type: 'string', short: 'a' }, replace: { type: 'boolean' }, check: { type: 'boolean' }, force: { type: 'boolean' }, 'dry-run': { type: 'boolean' } },
|
|
230
|
+
options: { alias: { type: 'string', short: 'a' }, replace: { type: 'boolean' }, check: { type: 'boolean' }, force: { type: 'boolean' }, 'dry-run': { type: 'boolean' }, only: { type: 'string', multiple: true } },
|
|
228
231
|
optionHelp: [
|
|
229
232
|
['-a, --alias <alias>', 'alias of the application (default: the one in the export)'],
|
|
230
233
|
['--replace', 'update the application with that alias in place'],
|
|
231
234
|
['--check', 'check the application\'s SQL and references first; errors refuse the import'],
|
|
232
235
|
['--force', 'with --check: import even when the check finds errors'],
|
|
233
236
|
['--dry-run', 'import, check and roll back: nothing changes'],
|
|
237
|
+
['--only <file>', 'with --replace: import only this page (pages/12) or component file or folder'],
|
|
234
238
|
],
|
|
235
239
|
positionals: [1, 1],
|
|
236
240
|
async run(v, [path]) {
|
|
237
|
-
const
|
|
241
|
+
const source = await readSource(path);
|
|
242
|
+
let { doc } = source;
|
|
243
|
+
const only = (v.only as string[] | undefined) ?? [];
|
|
244
|
+
if (only.length) {
|
|
245
|
+
// (116) a file-level import: the named files over the application's current files
|
|
246
|
+
if (!v.replace) throw new UsageError('--only needs --replace');
|
|
247
|
+
if (!source.files) throw new UsageError('--only needs a directory export or a .zip of one');
|
|
248
|
+
const target = (v.alias as string) ?? doc?.app?.alias;
|
|
249
|
+
const { mergeOnly } = await import('./only.ts');
|
|
250
|
+
const style = JSON.parse(source.files.get('pgkiln.json')?.toString('utf8') ?? '{}').style === 'text' ? 'text' : 'json';
|
|
251
|
+
const current = await withDb(async (db) => {
|
|
252
|
+
if (!(await db.query('select 1 from meta.app where alias = $1', [target])).rowCount) throw new UsageError(`--only: application ${target} not found`);
|
|
253
|
+
return docToFiles(await exportDoc(db, target), style);
|
|
254
|
+
});
|
|
255
|
+
const m = mergeOnly(current, source.files, only);
|
|
256
|
+
err(m.changed.length ? `Only ${m.labels.join(', ')}: ${m.changed.length} file(s) differ.\n` : `Only ${m.labels.join(', ')}: no file differs.\n`);
|
|
257
|
+
const { filesToDoc } = await import('../appfiles.ts');
|
|
258
|
+
doc = filesToDoc(m.files);
|
|
259
|
+
}
|
|
238
260
|
const alias = (v.alias as string) ?? doc?.app?.alias;
|
|
239
261
|
if (!alias) throw new Error('the export has no alias: use --alias');
|
|
240
262
|
const check = v['dry-run'] ? 'dry-run' : v.check ? (v.force ? 'force' : 'check') : 'off';
|