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
package/src/builder/advisor.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { templateProblem } from '../runtime/document.ts';
|
|
|
8
8
|
import { invokeStepReferences, stepProblems, stepWarnings } from '../workflow.ts';
|
|
9
9
|
import { handlerProblems } from '../runtime/rest.ts';
|
|
10
10
|
import { processProblems } from '../runtime/processes.ts';
|
|
11
|
+
import { urlConfigProblem, urlTarget } from '../runtime/url-region.ts';
|
|
12
|
+
import { configColumns, searchConfigProblems } from '../runtime/search.ts';
|
|
11
13
|
import { COMPONENTS } from './components.ts';
|
|
12
14
|
import { appEntries, type Entry } from './search.ts';
|
|
13
15
|
import { appHeader, BASE, developer, region, send, shell, type Req } from './ui.ts';
|
|
@@ -149,10 +151,16 @@ export async function checkSql(c: pg.PoolClient, sql: string, shape: SqlShape):
|
|
|
149
151
|
const ITEM_REF = /(?<![A-Za-z0-9_$&:])(?::|&)(P\d+_[A-Z0-9_]+)\b/gi;
|
|
150
152
|
const BUILT_IN = new Set(['MUST_NOT_BE_PUBLIC_USER']);
|
|
151
153
|
|
|
152
|
-
/**
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Run every check of an application. With `tx` (a client in a transaction,
|
|
156
|
+
* e.g. an import that hasn't committed yet) everything is read and checked
|
|
157
|
+
* on that client, inside a savepoint that is rolled back.
|
|
158
|
+
*/
|
|
159
|
+
export async function advise(appId: number, tx?: pg.ClientBase): Promise<{ findings: Finding[]; checked: number; plpgsqlCheck: boolean }> {
|
|
160
|
+
const query = (sql: string, params: unknown[] = []) => (tx ? tx.query(sql, params) : owner.query(sql, params));
|
|
161
|
+
const one = async (sql: string, params: unknown[] = []) => (await query(sql, params)).rows[0];
|
|
162
|
+
const app = await one('select * from meta.app where id = $1', [appId]);
|
|
163
|
+
const entries = await appEntries(appId, query);
|
|
156
164
|
const byKey = new Map(entries.map((e) => [`${e.kind}-${e.id}`, e]));
|
|
157
165
|
const findings: Finding[] = [];
|
|
158
166
|
let checked = 0;
|
|
@@ -160,15 +168,15 @@ export async function advise(appId: number): Promise<{ findings: Finding[]; chec
|
|
|
160
168
|
// rows with every column (the entries only carry the searchable fields)
|
|
161
169
|
const rowsOf = async (kind: string, table: string, scope: 'page' | 'app') =>
|
|
162
170
|
(scope === 'page'
|
|
163
|
-
? await
|
|
164
|
-
: await
|
|
171
|
+
? await query(`select t.*, p.page_no from ${table} t join meta.page p on p.id = t.page_id where p.app_id = $1`, [appId])
|
|
172
|
+
: await query(`select * from ${table} where app_id = $1`, [appId])
|
|
165
173
|
).rows.map((row) => ({ kind, row }));
|
|
166
174
|
const all = (await Promise.all(Object.entries(COMPONENTS).map(([kind, spec]) => rowsOf(kind, spec.table, spec.scope)))).flat();
|
|
167
175
|
|
|
168
|
-
// ---- SQL, as the application's role, rolled back
|
|
169
|
-
const c = await owner.pool.connect();
|
|
176
|
+
// ---- SQL, as the application's role, rolled back (a savepoint in the caller's transaction)
|
|
177
|
+
const c = (tx ?? (await owner.pool.connect())) as pg.PoolClient;
|
|
170
178
|
try {
|
|
171
|
-
await c.query('begin');
|
|
179
|
+
await c.query(tx ? 'savepoint advisor_all' : 'begin');
|
|
172
180
|
await c.query(`set local statement_timeout = '5s'`);
|
|
173
181
|
await c.query(`select set_config('pgkiln.app_id', $1, true)`, [String(appId)]);
|
|
174
182
|
if (app.db_role) await c.query(`set local role ${pg.escapeIdentifier(app.db_role)}`);
|
|
@@ -193,6 +201,26 @@ export async function advise(appId: number): Promise<{ findings: Finding[]; chec
|
|
|
193
201
|
if (problem) findings.push({ ...problem, entry, field: `Layer "${typeof l.name === 'string' ? l.name : ''}" query` });
|
|
194
202
|
}
|
|
195
203
|
}
|
|
204
|
+
// (080) search configurations: their source with the columns they name
|
|
205
|
+
for (const { kind, row } of all.filter((x) => x.kind === 'search_config' && x.row.source_type !== 'list')) {
|
|
206
|
+
const entry = byKey.get(`${kind}-${row.id}`) ?? null;
|
|
207
|
+
const problems = searchConfigProblems(row);
|
|
208
|
+
if (problems.length) {
|
|
209
|
+
findings.push({ severity: 'error', entry, field: 'Source', message: problems.join(' ') });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const cols = configColumns(row).map((x) => `t.${pg.escapeIdentifier(x)}`).join(', ') || '*';
|
|
213
|
+
checked++;
|
|
214
|
+
const problem = await checkSql(c, `select ${cols} from ${row.source_type === 'table' ? row.source.trim() : `(${row.source})`} t`, 'select');
|
|
215
|
+
if (problem) findings.push({ ...problem, entry, field: 'Source' });
|
|
216
|
+
}
|
|
217
|
+
// the search regions name existing configurations
|
|
218
|
+
for (const { kind, row } of all.filter((x) => x.kind === 'region' && x.row.type === 'search')) {
|
|
219
|
+
const names: unknown[] = Array.isArray(row.config?.configs) ? row.config.configs : [];
|
|
220
|
+
for (const n of names)
|
|
221
|
+
if (!all.some((x) => x.kind === 'search_config' && x.row.name === String(n).toUpperCase()))
|
|
222
|
+
findings.push({ severity: 'error', entry: byKey.get(`${kind}-${row.id}`) ?? null, field: 'Attributes', message: `Search configuration ${String(n)} doesn't exist.` });
|
|
223
|
+
}
|
|
196
224
|
// REST handlers
|
|
197
225
|
for (const { kind, row } of all.filter((x) => x.kind === 'rest_module')) {
|
|
198
226
|
const entry = byKey.get(`${kind}-${row.id}`) ?? null;
|
|
@@ -221,8 +249,13 @@ export async function advise(appId: number): Promise<{ findings: Finding[]; chec
|
|
|
221
249
|
}
|
|
222
250
|
}
|
|
223
251
|
} finally {
|
|
224
|
-
|
|
225
|
-
|
|
252
|
+
if (tx) {
|
|
253
|
+
await c.query('rollback to savepoint advisor_all');
|
|
254
|
+
await c.query('release savepoint advisor_all');
|
|
255
|
+
} else {
|
|
256
|
+
await c.query('rollback').catch(() => {});
|
|
257
|
+
c.release();
|
|
258
|
+
}
|
|
226
259
|
}
|
|
227
260
|
|
|
228
261
|
// ---- references
|
|
@@ -277,6 +310,13 @@ export async function advise(appId: number): Promise<{ findings: Finding[]; chec
|
|
|
277
310
|
if (kind === 'region' && row.type === 'grid' && !all.some((x) => x.kind === 'process' && x.row.type === 'grid_dml' && x.row.region_id === row.id))
|
|
278
311
|
missing(e, 'Source', 'No grid_dml process saves this grid, so it is read-only.', 'info');
|
|
279
312
|
if (kind === 'region' && row.type === 'form' && row.pk_item && !items.has(String(row.pk_item).toUpperCase())) missing(e, 'Primary key item', `Item ${row.pk_item} doesn't exist.`);
|
|
313
|
+
// (078) a URL region: a literal origin, valid attributes
|
|
314
|
+
if (kind === 'region' && row.type === 'url') {
|
|
315
|
+
const target = urlTarget(row.source);
|
|
316
|
+
if ('error' in target) missing(e, 'Source', target.error);
|
|
317
|
+
const problem = urlConfigProblem(row.config);
|
|
318
|
+
if (problem) missing(e, 'Attributes', problem);
|
|
319
|
+
}
|
|
280
320
|
if (kind === 'computation' && !items.has(String(row.item_name).toUpperCase())) missing(e, 'Item', `Item ${row.item_name} doesn't exist (the computation sets nothing).`);
|
|
281
321
|
if (kind === 'computation' && row.type === 'item' && !items.has(String(row.expression).toUpperCase())) missing(e, 'Expression', `Item ${row.expression} doesn't exist (its value is always empty).`, 'warning');
|
|
282
322
|
if (kind === 'process' && (row.type === 'form_dml' || row.type === 'grid_dml') && !row.region_id) missing(e, 'Region', `A ${row.type} process needs its region.`);
|
|
@@ -290,7 +330,7 @@ export async function advise(appId: number): Promise<{ findings: Finding[]; chec
|
|
|
290
330
|
}
|
|
291
331
|
if (kind === 'branch' && row.target_type === 'page' && row.target_page && !pageNos.has(Number(row.target_page))) missing(e, 'Page', `Page ${row.target_page} doesn't exist.`);
|
|
292
332
|
if (kind === 'branch' && row.target_type === 'app'
|
|
293
|
-
&& !(await
|
|
333
|
+
&& !(await one('select 1 as ok from meta.app a join meta.page p on p.app_id = a.id where a.alias = $1 and p.page_no = $2', [row.target_app, row.target_page])))
|
|
294
334
|
missing(e, 'Application (app)', `Application ${row.target_app} has no page ${row.target_page}.`);
|
|
295
335
|
if (kind === 'button' && row.action === 'document' && !documents.has(String(row.document ?? '').toUpperCase()))
|
|
296
336
|
missing(e, 'Document template', row.document ? `Document template ${row.document} doesn't exist.` : 'A document button needs a document template.');
|
|
@@ -324,9 +364,9 @@ export async function advise(appId: number): Promise<{ findings: Finding[]; chec
|
|
|
324
364
|
}
|
|
325
365
|
|
|
326
366
|
// ---- PL/pgSQL functions of the app's schemas, with plpgsql_check when installed
|
|
327
|
-
const plpgsqlCheck = !!(await
|
|
367
|
+
const plpgsqlCheck = !!(await one(`select 1 as ok from pg_extension where extname = 'plpgsql_check'`));
|
|
328
368
|
if (plpgsqlCheck && app.db_role) {
|
|
329
|
-
const res = await
|
|
369
|
+
const res = await query(
|
|
330
370
|
`select p.oid::regprocedure::text as fn, r.level, r.message, r.lineno
|
|
331
371
|
from pg_proc p
|
|
332
372
|
join pg_namespace n on n.oid = p.pronamespace
|
package/src/builder/arrange.ts
CHANGED
|
@@ -29,7 +29,7 @@ export const REGION_LABELS: Record<string, [string, string]> = {
|
|
|
29
29
|
cards: ['Cards', 'layers'], calendar: ['Calendar', 'calendar'], facets: ['Faceted search', 'filter'], smart_filters: ['Smart filters', 'search'], display_selector: ['Region display selector', 'menu'], tasks: ['Task list', 'inbox'],
|
|
30
30
|
workflows: ['Workflow console', 'activity'], map: ['Map', 'map'], tree: ['Tree', 'org'], static: ['Static content', 'region'], dynamic: ['Dynamic content', 'code'],
|
|
31
31
|
template_component: ['Template component', 'layers'], list: ['List', 'list'], data_reporter: ['Data Reporter', 'table'],
|
|
32
|
-
ai_assistant: ['AI assistant', 'bolt'],
|
|
32
|
+
ai_assistant: ['AI assistant', 'bolt'], url: ['URL', 'link'], search: ['Search', 'search'],
|
|
33
33
|
};
|
|
34
34
|
export const ITEM_LABELS: Record<string, [string, string]> = {
|
|
35
35
|
text: ['Text field', 'item'], textarea: ['Text area', 'item'], number: ['Number field', 'item'], date: ['Date picker', 'calendar'],
|
|
@@ -37,7 +37,7 @@ export const ITEM_LABELS: Record<string, [string, string]> = {
|
|
|
37
37
|
checkbox: ['Checkbox', 'check'], switch: ['Switch', 'check'], checkbox_group: ['Checkbox group', 'check'], multiselect: ['Multi select', 'list'],
|
|
38
38
|
email: ['E-mail', 'inbox'], tel: ['Phone number', 'item'], url: ['URL', 'item'], color: ['Color picker', 'item'], file: ['File upload', 'upload'],
|
|
39
39
|
location: ['Location', 'map'], richtext: ['Rich text editor', 'edit'], markdown: ['Markdown editor', 'code'], rating: ['Star rating', 'check'],
|
|
40
|
-
combobox: ['Combobox (tags)', 'list'], daterange: ['Date range', 'calendar'], qrcode: ['QR code', 'scan'], hidden: ['Hidden', 'item'], display: ['Display only', 'file'], password: ['Password', 'key'],
|
|
40
|
+
combobox: ['Combobox (tags)', 'list'], daterange: ['Date range', 'calendar'], qrcode: ['QR code', 'scan'], image: ['Display image', 'file'], address: ['Geocoded address', 'map'], hidden: ['Hidden', 'item'], display: ['Display only', 'file'], password: ['Password', 'key'],
|
|
41
41
|
};
|
|
42
42
|
export const BUTTON_LABELS: Record<string, [string, string]> = {
|
|
43
43
|
submit: ['Submit page', 'button'], redirect: ['Redirect to page', 'button'], da: ['Dynamic action', 'bolt'], document: ['Download document', 'download'],
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { owner } from '../db.ts';
|
|
3
|
+
import { html, raw } from '../html.ts';
|
|
4
|
+
import { clientIp, logActivity } from '../session.ts';
|
|
5
|
+
import { appOr404 } from './forms.ts';
|
|
6
|
+
import { appHeader, back, BASE, csrf, developer, flash, region, select, send, shell, type Req } from './ui.ts';
|
|
7
|
+
|
|
8
|
+
// Application → Availability (APEX: Application Availability and Build Status,
|
|
9
|
+
// migration 083): who may use the running application, the message they see
|
|
10
|
+
// otherwise, and whether the builder may change it (Run only). Allowed on a
|
|
11
|
+
// Run only application (src/builder/locks.ts), logged as "availability".
|
|
12
|
+
|
|
13
|
+
const MODES: [string, string][] = [
|
|
14
|
+
['available', 'Available'],
|
|
15
|
+
['developers', 'Available to developers only'],
|
|
16
|
+
['restricted', 'Restricted to the users below (and developers)'],
|
|
17
|
+
['unavailable', 'Unavailable'],
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export async function availabilityRoutes(app: FastifyInstance) {
|
|
21
|
+
app.get(`${BASE}/apps/:id(^\\d+$)/availability`, async (req: Req, reply) => {
|
|
22
|
+
const s = await developer(req, reply);
|
|
23
|
+
if (!s) return;
|
|
24
|
+
const a = await appOr404(req.params.id);
|
|
25
|
+
if (!a) return reply.code(404).send('Not found');
|
|
26
|
+
const body = region('Availability', html`<form method="post" action="${BASE}/apps/${a.id}/availability">${csrf(s)}
|
|
27
|
+
<p class="muted u-mt0">Who may use the running application. Developers are those signed in to the builder in the same browser
|
|
28
|
+
(they see the developer toolbar). Pages, file downloads, REST modules and the REST API follow this setting; automations,
|
|
29
|
+
workflows, background jobs and queued mail keep running. It belongs to this installation: exports leave it out and an
|
|
30
|
+
import over the application keeps it.</p>
|
|
31
|
+
<div class="form-grid">
|
|
32
|
+
${select('availability', 'Status', a.availability, MODES)}
|
|
33
|
+
<div class="field" data-wide><label class="label" for="f_message">Message</label>
|
|
34
|
+
<textarea id="f_message" name="message" rows="3" maxlength="2000">${a.availability_message ?? ''}</textarea>
|
|
35
|
+
<small class="help">Shown when the application is unavailable or for developers only, e.g. "We are upgrading the application; back at 14:00." Empty: a standard message in the user's language.</small></div>
|
|
36
|
+
<div class="field" data-wide><label class="label" for="f_users">Users (restricted)</label>
|
|
37
|
+
<textarea id="f_users" name="users" rows="2">${(a.availability_users ?? []).join(', ')}</textarea>
|
|
38
|
+
<small class="help">User names separated by commas or spaces. They sign in as usual; others get "not available to your account".</small></div>
|
|
39
|
+
${select('build_status', 'Build status', a.build_status, [['run_and_build', 'Run and build'], ['run_only', 'Run only']],
|
|
40
|
+
'Run only: the builder refuses changes to the application (pages, shared components, settings, restores) until it is set back here. pgkiln import --replace still deploys it.')}
|
|
41
|
+
</div>
|
|
42
|
+
<div class="buttons"><button class="btn btn-hot">Save</button></div></form>`);
|
|
43
|
+
const main = html`${appHeader(a, 'settings')}<div class="ide-body">${body}</div>`;
|
|
44
|
+
return send(reply, s, shell(s, `${a.name} availability`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Availability']], main));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
app.post(`${BASE}/apps/:id(^\\d+$)/availability`, async (req: Req, reply) => {
|
|
48
|
+
const s = await developer(req, reply);
|
|
49
|
+
if (!s) return;
|
|
50
|
+
const b = req.body ?? {};
|
|
51
|
+
const mode = MODES.some(([v]) => v === b.availability) ? b.availability : 'available';
|
|
52
|
+
const users = [...new Set(String(b.users ?? '').split(/[\s,;]+/).map((u) => u.trim()).filter(Boolean))];
|
|
53
|
+
if (users.some((u) => u.length > 100 || /[:\s]/.test(u))) {
|
|
54
|
+
flash(s, 'User names are at most 100 characters, without spaces or colons.', 'error');
|
|
55
|
+
return back(reply, s, `${BASE}/apps/${req.params.id}/availability`);
|
|
56
|
+
}
|
|
57
|
+
const build = b.build_status === 'run_only' ? 'run_only' : 'run_and_build';
|
|
58
|
+
const r = await owner.query(
|
|
59
|
+
`update meta.app set availability = $2, availability_message = $3, availability_users = $4, build_status = $5, updated_at = now() where id = $1`,
|
|
60
|
+
[req.params.id, mode, String(b.message ?? '').trim().slice(0, 2000) || null, users, build]);
|
|
61
|
+
if (r.rowCount) await logActivity({ appId: Number(req.params.id), username: s.username, event: 'availability', ip: clientIp(req), detail: `${mode}, ${build}` });
|
|
62
|
+
flash(s, r.rowCount ? 'Availability saved.' : 'Not found.', r.rowCount ? 'ok' : 'error');
|
|
63
|
+
return back(reply, s, `${BASE}/apps/${req.params.id}/availability`);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A badge for an application that is not simply available, or Run only. */
|
|
68
|
+
export const availabilityBadge = (a: { id: number; availability?: string; build_status?: string }) =>
|
|
69
|
+
a.availability && a.availability !== 'available' || a.build_status === 'run_only'
|
|
70
|
+
? html`<a class="tag tag-warning availability-badge" href="${BASE}/apps/${a.id}/availability">${[
|
|
71
|
+
a.availability && a.availability !== 'available' ? MODES.find(([v]) => v === a.availability)?.[1] : null,
|
|
72
|
+
a.build_status === 'run_only' ? 'Run only' : null,
|
|
73
|
+
].filter(Boolean).join(' · ')}</a>`
|
|
74
|
+
: raw('');
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { replaceApp } from '../cli/replace.ts';
|
|
3
|
+
import { owner } from '../db.ts';
|
|
4
|
+
import { html } from '../html.ts';
|
|
5
|
+
import { icon } from '../icons.ts';
|
|
6
|
+
import { clientIp, logActivity } from '../session.ts';
|
|
7
|
+
import { appOr404 } from './forms.ts';
|
|
8
|
+
import { appHeader, back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
|
|
9
|
+
|
|
10
|
+
// Application → Backups (APEX: Application Backups, migration 082): the
|
|
11
|
+
// exports this installation keeps of an application (before every import
|
|
12
|
+
// that replaces it, a merge or a restore; daily when it changed; on demand),
|
|
13
|
+
// with download, restore and delete. A restore is an import --replace of the
|
|
14
|
+
// backup, so the version it replaces is backed up as well. Locks of the
|
|
15
|
+
// application are respected (developer() refuses a POST to a locked app).
|
|
16
|
+
|
|
17
|
+
const REASON: Record<string, string> = {
|
|
18
|
+
import: 'before an import', merge: 'before a working copy merge', restore: 'before a restore', daily: 'daily', manual: 'made by hand',
|
|
19
|
+
};
|
|
20
|
+
const when = (d: string | Date) => new Date(d).toISOString().slice(0, 16).replace('T', ' ');
|
|
21
|
+
const kb = (n: number) => (n < 1024 * 1024 ? `${Math.max(1, Math.round(n / 1024))} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
|
|
22
|
+
|
|
23
|
+
/** Back up the applications whose newest backup is older than a day and that changed since (the scheduler). */
|
|
24
|
+
export async function backupTick() {
|
|
25
|
+
const due = (await owner.query<{ id: number }>(
|
|
26
|
+
`select a.id from meta.app a
|
|
27
|
+
where a.backups_keep > 0
|
|
28
|
+
and not exists (select 1 from meta.app_backup b where b.app_id = a.id and b.created_at > now() - interval '1 day')`)).rows;
|
|
29
|
+
for (const a of due) await owner.query(`select meta.backup_app($1, 'daily', 'scheduler')`, [a.id]).catch((e) => console.error(`backup of application ${a.id}:`, (e as Error).message));
|
|
30
|
+
return due.length;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function backupRoutes(app: FastifyInstance) {
|
|
34
|
+
app.get(`${BASE}/apps/:id(^\\d+$)/backups`, async (req: Req, reply) => {
|
|
35
|
+
const s = await developer(req, reply);
|
|
36
|
+
if (!s) return;
|
|
37
|
+
const a = await appOr404(req.params.id);
|
|
38
|
+
if (!a) return reply.code(404).send('Not found');
|
|
39
|
+
const rows = (await owner.query(
|
|
40
|
+
'select id, created_at, created_by, reason, note, size from meta.app_backup where app_id = $1 order by created_at desc, id desc', [a.id])).rows;
|
|
41
|
+
const body = html`
|
|
42
|
+
${region('Backups', html`
|
|
43
|
+
<p class="muted u-mt0">Exports of this application kept by this installation: one before every import that replaces it (also from the
|
|
44
|
+
command line and the MCP server), before a working copy merge and a restore, one a day when it changed, and the ones made here.
|
|
45
|
+
The newest ${a.backups_keep} are kept. Backups are not exported.</p>
|
|
46
|
+
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Made (UTC)</th><th>Why</th><th>By</th><th>Note</th><th class="num">Size</th><th>Actions</th></tr></thead><tbody>
|
|
47
|
+
${rows.length ? rows.map((b) => html`<tr>
|
|
48
|
+
<td data-label="Made">${when(b.created_at)}</td>
|
|
49
|
+
<td data-label="Why">${REASON[b.reason] ?? b.reason}</td>
|
|
50
|
+
<td data-label="By">${b.created_by ?? ''}</td>
|
|
51
|
+
<td data-label="Note" class="u-break">${b.note ?? ''}</td>
|
|
52
|
+
<td class="num" data-label="Size">${kb(b.size)}</td>
|
|
53
|
+
<td data-label="Actions"><div class="buttons">
|
|
54
|
+
<a class="btn btn-sm" href="${BASE}/apps/${a.id}/backups/${b.id}/download">${icon('download')} Download</a>
|
|
55
|
+
<form method="post" action="${BASE}/apps/${a.id}/backups/${b.id}/restore" class="u-inline">${csrf(s)}
|
|
56
|
+
<button class="btn btn-sm" data-confirm="Restore the application as it was on ${when(b.created_at)}? Its pages, shared components and settings become those of the backup; the current version is backed up first.">${icon('history')} Restore</button></form>
|
|
57
|
+
<form method="post" action="${BASE}/apps/${a.id}/backups/${b.id}/delete" class="u-inline">${csrf(s)}
|
|
58
|
+
<button class="btn btn-sm btn-danger" data-confirm="Delete this backup?">Delete</button></form>
|
|
59
|
+
</div></td></tr>`)
|
|
60
|
+
: html`<tr><td colspan="6" class="empty">No backups yet.</td></tr>`}</tbody></table></div>`)}
|
|
61
|
+
<div class="columns">
|
|
62
|
+
${region('Back up now', html`<form method="post" action="${BASE}/apps/${a.id}/backups">${csrf(s)}
|
|
63
|
+
<div class="form-grid">${input('note', 'Note', '', { placeholder: 'e.g. before the new dashboard' })}</div>
|
|
64
|
+
<div class="buttons"><button class="btn btn-hot">${icon('save')} Back up now</button></div></form>`)}
|
|
65
|
+
${region('Keep', html`<form method="post" action="${BASE}/apps/${a.id}/backups/settings">${csrf(s)}
|
|
66
|
+
<div class="form-grid">${input('keep', 'Backups to keep', a.backups_keep, { type: 'number', help: '0 to 500; 0 makes no backups at all (also not before an import).' })}</div>
|
|
67
|
+
<div class="buttons"><button class="btn">Save</button></div></form>`)}
|
|
68
|
+
</div>`;
|
|
69
|
+
const main = html`${appHeader(a, 'pages')}<div class="ide-body">${body}</div>`;
|
|
70
|
+
return send(reply, s, shell(s, `${a.name} backups`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Backups']], main));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
app.post(`${BASE}/apps/:id(^\\d+$)/backups`, async (req: Req, reply) => {
|
|
74
|
+
const s = await developer(req, reply);
|
|
75
|
+
if (!s) return;
|
|
76
|
+
const a = await appOr404(req.params.id);
|
|
77
|
+
if (!a) return reply.code(404).send('Not found');
|
|
78
|
+
const r = await owner.one(`select meta.backup_app($1, 'manual', $2, $3) as id`, [a.id, s.username, String(req.body?.note ?? '').trim().slice(0, 500) || null]);
|
|
79
|
+
flash(s, r.id ? 'Backup made.' : 'No backup made: this application keeps none (set "Backups to keep").', r.id ? 'ok' : 'error');
|
|
80
|
+
return back(reply, s, `${BASE}/apps/${a.id}/backups`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
app.post(`${BASE}/apps/:id(^\\d+$)/backups/settings`, async (req: Req, reply) => {
|
|
84
|
+
const s = await developer(req, reply);
|
|
85
|
+
if (!s) return;
|
|
86
|
+
const keep = Number(req.body?.keep);
|
|
87
|
+
if (!Number.isInteger(keep) || keep < 0 || keep > 500) flash(s, 'Backups to keep: a whole number from 0 to 500.', 'error');
|
|
88
|
+
else {
|
|
89
|
+
await owner.query('update meta.app set backups_keep = $2, updated_at = now() where id = $1', [req.params.id, keep]);
|
|
90
|
+
// fewer to keep: the oldest go now
|
|
91
|
+
await owner.query(`delete from meta.app_backup where app_id = $1 and id not in (select id from meta.app_backup where app_id = $1 order by created_at desc, id desc limit $2)`, [req.params.id, keep]);
|
|
92
|
+
flash(s, 'Saved.');
|
|
93
|
+
}
|
|
94
|
+
return back(reply, s, `${BASE}/apps/${req.params.id}/backups`);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
app.get(`${BASE}/apps/:id(^\\d+$)/backups/:bid(^\\d+$)/download`, async (req: Req, reply) => {
|
|
98
|
+
const s = await developer(req, reply);
|
|
99
|
+
if (!s) return;
|
|
100
|
+
const b = await owner.one(`select b.doc, b.created_at, a.alias from meta.app_backup b join meta.app a on a.id = b.app_id where b.id = $1 and b.app_id = $2`, [req.params.bid, req.params.id]);
|
|
101
|
+
if (!b) return reply.code(404).send('Not found');
|
|
102
|
+
const name = `${b.alias}-${new Date(b.created_at).toISOString().slice(0, 16).replace(/[-:]/g, '').replace('T', '-')}.json`;
|
|
103
|
+
return reply.header('content-disposition', `attachment; filename="${name.replace(/[^\w.-]/g, '_')}"`).header('cache-control', 'private, no-store')
|
|
104
|
+
.type('application/json').send(JSON.stringify(b.doc, null, 2));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
app.post(`${BASE}/apps/:id(^\\d+$)/backups/:bid(^\\d+$)/restore`, async (req: Req, reply) => {
|
|
108
|
+
const s = await developer(req, reply);
|
|
109
|
+
if (!s) return;
|
|
110
|
+
const a = await appOr404(req.params.id);
|
|
111
|
+
if (!a) return reply.code(404).send('Not found');
|
|
112
|
+
try {
|
|
113
|
+
const at = await owner.tx(async (c) => {
|
|
114
|
+
const b = (await c.query('select doc, created_at from meta.app_backup where id = $1 and app_id = $2', [req.params.bid, a.id])).rows[0];
|
|
115
|
+
if (!b) throw new Error('Backup not found.');
|
|
116
|
+
await replaceApp(c, b.doc, a.alias, { backup: { reason: 'restore', user: s.username, note: `before restoring the backup of ${when(b.created_at)}` } });
|
|
117
|
+
return when(b.created_at);
|
|
118
|
+
});
|
|
119
|
+
await logActivity({ appId: a.id, username: s.username, event: 'backup_restored', ip: clientIp(req), detail: `backup ${req.params.bid} of ${at}` });
|
|
120
|
+
flash(s, `Restored the application as it was on ${at}. The version it replaced is the newest backup.`);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
flash(s, `Restore failed: ${(e as Error).message}`, 'error');
|
|
123
|
+
}
|
|
124
|
+
return back(reply, s, `${BASE}/apps/${a.id}/backups`);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
app.post(`${BASE}/apps/:id(^\\d+$)/backups/:bid(^\\d+$)/delete`, async (req: Req, reply) => {
|
|
128
|
+
const s = await developer(req, reply);
|
|
129
|
+
if (!s) return;
|
|
130
|
+
const r = await owner.query('delete from meta.app_backup where id = $1 and app_id = $2', [req.params.bid, req.params.id]);
|
|
131
|
+
flash(s, r.rowCount ? 'Backup deleted.' : 'Not found.', r.rowCount ? 'ok' : 'error');
|
|
132
|
+
return back(reply, s, `${BASE}/apps/${req.params.id}/backups`);
|
|
133
|
+
});
|
|
134
|
+
}
|