pgkiln 0.47.0 → 0.49.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/SECURITY.md +2 -2
- package/db/migrations/109_builder_pins.sql +19 -0
- package/db/migrations/110_sql_charts.sql +23 -0
- package/db/migrations/111_sql_diagram.sql +24 -0
- package/db/migrations/113_rest_objects.sql +49 -0
- package/db/migrations/116_dump_jobs.sql +41 -0
- package/db/migrations/117_rest_handlers.sql +63 -0
- package/db/migrations/118_change_log.sql +54 -0
- package/docs/apex-feature-parity.md +11 -11
- package/docs/guide/01-installation.md +4 -0
- package/docs/guide/03-builder.md +267 -7
- package/docs/guide/08-security.md +29 -0
- package/docs/guide/12-development.md +70 -6
- package/docs/guide/13-rest-api.md +133 -10
- package/docs/guide/15-extensions.md +1 -1
- package/examples/hr/hr_62_rest_module.sql +39 -0
- package/package.json +1 -1
- package/public/app.css +1 -1
- package/public/builder-icons.svg +2 -0
- package/public/builder.css +442 -173
- package/public/builder.js +48 -0
- package/public/data-modeler.css +56 -0
- package/public/data-modeler.js +367 -0
- package/public/palette.js +133 -0
- package/public/sql-monitor.js +73 -0
- package/public/worksheet.css +49 -0
- package/public/worksheet.js +4 -0
- package/src/app.ts +10 -0
- package/src/automations.ts +6 -0
- package/src/binds.ts +2 -1
- package/src/builder/advisor.ts +24 -2
- package/src/builder/autorest.ts +363 -0
- package/src/builder/changelog.ts +240 -0
- package/src/builder/components.ts +1 -1
- package/src/builder/data-modeler.ts +382 -0
- package/src/builder/datapump.ts +364 -0
- package/src/builder/db-users.ts +472 -0
- package/src/builder/ddl-tools.ts +13 -4
- package/src/builder/find-workspace.ts +2 -2
- package/src/builder/launchpad.ts +140 -0
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/object-browser.ts +7 -1
- package/src/builder/plan-view.ts +25 -0
- package/src/builder/rest-modules.ts +629 -0
- package/src/builder/rest.ts +1 -0
- package/src/builder/routes.ts +4 -2
- package/src/builder/scheduling.ts +495 -0
- package/src/builder/sql-charts.ts +203 -0
- package/src/builder/sql-monitor.ts +397 -0
- package/src/builder/sql-tools.ts +53 -0
- package/src/builder/sql-utilities.ts +7 -39
- package/src/builder/sql.ts +20 -9
- package/src/builder/table-editor.ts +4 -1
- package/src/builder/top-sql.ts +24 -6
- package/src/builder/ui.ts +25 -17
- package/src/builder/worksheet.ts +35 -28
- package/src/builder/workspaces.ts +4 -2
- package/src/changelog.ts +109 -0
- package/src/cli/replace.ts +3 -1
- package/src/cron-schedule.ts +282 -0
- package/src/datamodel.ts +324 -0
- package/src/datapump.ts +546 -0
- package/src/dbroles.ts +215 -0
- package/src/dumpscript.ts +407 -0
- package/src/pgcron.ts +225 -0
- package/src/runtime/autorest.ts +336 -0
- package/src/runtime/rest-handlers.ts +75 -0
- package/src/runtime/rest.ts +120 -26
- package/src/session.ts +2 -0
package/public/worksheet.css
CHANGED
|
@@ -242,3 +242,52 @@ details.menu.obj-menu > summary::before, details.menu.obj-menu > summary::after
|
|
|
242
242
|
.te-generated { font-size: 12px; }
|
|
243
243
|
.te-ddl { max-height: 60vh; overflow: auto; }
|
|
244
244
|
.te-buttons { position: sticky; bottom: 0; background: var(--bg); padding: 8px 0; border-top: 1px solid var(--border); }
|
|
245
|
+
|
|
246
|
+
/* (sprint 96) the header's search, the search dialog, the SQL Workshop Launchpad, pins */
|
|
247
|
+
.ide-search { margin-left: auto; min-width: 0; }
|
|
248
|
+
.ide-search input { width: 13rem; max-width: 100%; font-size: 12.5px; padding: 3px 8px; }
|
|
249
|
+
@media (max-width: 700px) { .ide-search input { width: 7rem; } }
|
|
250
|
+
.palette { width: min(40rem, calc(100vw - 32px)); margin-top: 12vh; padding: 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); }
|
|
251
|
+
.palette::backdrop { background: rgb(0 0 0 / 35%); }
|
|
252
|
+
.palette input { width: 100%; border: 0; border-bottom: 1px solid var(--border); border-radius: 0; padding: 12px 14px; font-size: 15px; }
|
|
253
|
+
.palette-list { list-style: none; margin: 0; padding: 4px 0; max-height: 50vh; overflow: auto; }
|
|
254
|
+
.palette-list li { padding: 6px 14px; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
255
|
+
.palette-list li[aria-selected=true] { background: var(--sel-bg); color: var(--sel-text); }
|
|
256
|
+
.palette-list li[aria-selected=true] .muted { color: inherit; opacity: 0.85; }
|
|
257
|
+
.palette-status { margin: 0; padding: 6px 14px; border-top: 1px solid var(--border); }
|
|
258
|
+
.pal-results { list-style: none; padding: 0; }
|
|
259
|
+
.pal-results a { display: flex; gap: 8px; align-items: center; padding: 6px 0; text-decoration: none; }
|
|
260
|
+
.lp-pins { margin-bottom: 16px; }
|
|
261
|
+
.lp-pins h2, .lp-group h2 { font-size: 14px; margin: 0 0 8px; }
|
|
262
|
+
.lp-chips { list-style: none; margin: 0 0 8px; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
|
|
263
|
+
.lp-chips li { display: flex; align-items: center; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); padding-left: 10px; }
|
|
264
|
+
.lp-chips a { display: inline-flex; align-items: center; gap: 6px; text-decoration: none; color: var(--text); padding: 4px 0; font-size: 13px; }
|
|
265
|
+
.lp-recent li { padding-right: 10px; border-style: dashed; }
|
|
266
|
+
.lp-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 10px; }
|
|
267
|
+
.lp-tabs [role=tab] { border: 0; background: none; color: var(--muted); padding: 7px 12px; font: inherit; cursor: pointer; }
|
|
268
|
+
.lp-tabs [role=tab][aria-selected=true] { color: var(--text); box-shadow: inset 0 -2px 0 var(--hot); }
|
|
269
|
+
.js .lp-group > h2 { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
|
270
|
+
.lp-list { list-style: none; margin: 0 0 16px; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 8px; }
|
|
271
|
+
.lp-tool { display: flex; align-items: flex-start; gap: 4px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); padding: 8px; }
|
|
272
|
+
.lp-tool > a { flex: 1; display: flex; gap: 8px; text-decoration: none; color: var(--text); min-width: 0; }
|
|
273
|
+
.lp-tool > a span { display: flex; flex-direction: column; min-width: 0; }
|
|
274
|
+
.lp-tool small { font-size: 12px; }
|
|
275
|
+
.pin-form { display: inline; margin: 0; }
|
|
276
|
+
.pin-btn { border: 0; background: none; color: var(--muted); cursor: pointer; padding: 2px 6px; border-radius: 3px; }
|
|
277
|
+
.pin-btn:hover { color: var(--text); background: var(--surface-3); }
|
|
278
|
+
.pin-btn.is-pinned { color: var(--hot); }
|
|
279
|
+
.pin-btn .icon { width: 14px; height: 14px; }
|
|
280
|
+
.ob-pin { float: right; }
|
|
281
|
+
|
|
282
|
+
/* (sprint 97) charts: the worksheet's Chart tab, the Charts page and its dashboard */
|
|
283
|
+
.ch-controls { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: flex-end; margin-bottom: 8px; font-size: 13px; }
|
|
284
|
+
.ch-controls > label { display: flex; flex-direction: column; gap: 2px; }
|
|
285
|
+
.ch-controls fieldset { display: flex; flex-wrap: wrap; gap: 4px 10px; border: 1px solid var(--border); border-radius: var(--radius); padding: 4px 8px; margin: 0; }
|
|
286
|
+
.ch-controls legend { font-size: 12px; color: var(--muted); padding: 0 4px; }
|
|
287
|
+
.ch-actions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 8px; }
|
|
288
|
+
.ch-actions input { flex: 0 1 16rem; width: auto; min-width: 0; }
|
|
289
|
+
.ch-view { min-width: 0; max-width: 60rem; }
|
|
290
|
+
.ch-dashboard { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 420px), 1fr)); gap: 12px; margin-bottom: 16px; }
|
|
291
|
+
.ch-card { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); padding: 10px 12px; min-width: 0; }
|
|
292
|
+
.ch-card h2 { font-size: 14px; margin: 0 0 6px; }
|
|
293
|
+
.ch-edit { max-width: 48rem; margin-top: 16px; }
|
package/public/worksheet.js
CHANGED
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
const k = e.key.toLowerCase();
|
|
65
65
|
if (k === 's' && !e.shiftKey) { save(); return true; }
|
|
66
66
|
if (e.key === 'F7') { format(); return true; }
|
|
67
|
+
if (e.key === 'F11' && button('chart')) { run('chart'); return true; }
|
|
67
68
|
if (!inEditor && !e.target.closest?.('.ws-findbar')) return false;
|
|
68
69
|
if (k === 'b' && inEditor) { convertCase(); return true; }
|
|
69
70
|
if (k === 'f' || k === 'h' || k === 'g') { openFind(k); return true; }
|
|
@@ -339,6 +340,9 @@
|
|
|
339
340
|
form.querySelector('.ws-nav-switch').addEventListener('click', (e) => { const b = e.target.closest('[data-ws-navtab]'); if (b) navTab(b.dataset.wsNavtab); });
|
|
340
341
|
navTab(location.hash === '#ws-files' ? 'files' : 'objects');
|
|
341
342
|
|
|
343
|
+
// ------------------------------------------------------------ (sprint 97) a chart's settings draw it again
|
|
344
|
+
form.querySelector('.ch-controls')?.addEventListener('change', () => run('chart'));
|
|
345
|
+
|
|
342
346
|
// ------------------------------------------------------------ (sprint 93) bind and substitution values
|
|
343
347
|
const binds = form.querySelector('[data-ws-binds]');
|
|
344
348
|
if (binds) {
|
package/src/app.ts
CHANGED
|
@@ -37,6 +37,10 @@ import { reportSettingsRoutes } from './builder/report-settings.ts';
|
|
|
37
37
|
import { layoutRoutes } from './builder/layouts.ts';
|
|
38
38
|
import { dataLoadRoutes } from './builder/dataload.ts';
|
|
39
39
|
import { builderRoutes } from './builder/routes.ts';
|
|
40
|
+
import { dbUserRoutes } from './builder/db-users.ts';
|
|
41
|
+
import { dataPumpRoutes } from './builder/datapump.ts';
|
|
42
|
+
import { schedulingRoutes } from './builder/scheduling.ts';
|
|
43
|
+
import { sqlMonitorRoutes } from './builder/sql-monitor.ts';
|
|
40
44
|
import { usersRoutes } from './builder/users.ts';
|
|
41
45
|
import { codeEditorRoutes } from './builder/code-editor.ts';
|
|
42
46
|
import { sampleDataRoutes } from './builder/sampledata.ts';
|
|
@@ -59,6 +63,7 @@ import { staticFileRoutes } from './runtime/static-files.ts';
|
|
|
59
63
|
import { staticFileBuilderRoutes } from './builder/static-files.ts';
|
|
60
64
|
import { pluginRoutes } from './builder/plugins.ts';
|
|
61
65
|
import { restRoutes } from './runtime/rest.ts';
|
|
66
|
+
import { autoRestRoutes } from './runtime/autorest.ts';
|
|
62
67
|
import { oauthRoutes } from './oauth.ts';
|
|
63
68
|
import { MAX_UPLOAD_MB } from './runtime/files.ts';
|
|
64
69
|
import { runtimeRoutes } from './runtime/routes.ts';
|
|
@@ -183,6 +188,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
183
188
|
await app.register(pushRoutes);
|
|
184
189
|
await app.register(staticFileRoutes);
|
|
185
190
|
await app.register(restRoutes);
|
|
191
|
+
await app.register(autoRestRoutes); // (sprint 100) AutoREST: /a/:alias/autorest/
|
|
186
192
|
await app.register(builderRoutes);
|
|
187
193
|
await app.register(galleryRoutes);
|
|
188
194
|
await app.register(appGroupRoutes);
|
|
@@ -199,6 +205,10 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
199
205
|
await app.register(pluginRoutes);
|
|
200
206
|
await app.register(sqlRoutes);
|
|
201
207
|
await app.register(usersRoutes);
|
|
208
|
+
await app.register(dbUserRoutes);
|
|
209
|
+
await app.register(dataPumpRoutes);
|
|
210
|
+
await app.register(schedulingRoutes);
|
|
211
|
+
await app.register(sqlMonitorRoutes); // (sprint 102) SQL Workshop › SQL Monitor
|
|
202
212
|
await app.register(dataLoadRoutes);
|
|
203
213
|
await app.register(layoutRoutes);
|
|
204
214
|
await app.register(automationRoutes);
|
package/src/automations.ts
CHANGED
|
@@ -330,6 +330,12 @@ export function startScheduler() {
|
|
|
330
330
|
} catch (e) {
|
|
331
331
|
console.error('AI requests:', (e as Error).message);
|
|
332
332
|
}
|
|
333
|
+
try {
|
|
334
|
+
// (116) schema export / import: interrupted jobs, files past PGKILN_DUMP_KEEP_DAYS (src/datapump.ts)
|
|
335
|
+
await (await import('./datapump.ts')).dumpTick();
|
|
336
|
+
} catch (e) {
|
|
337
|
+
console.error('schema export / import:', (e as Error).message);
|
|
338
|
+
}
|
|
333
339
|
try {
|
|
334
340
|
// debug messages past their retention (at most once an hour; src/debug.ts)
|
|
335
341
|
await (await import('./debug.ts')).purgeDebug();
|
package/src/binds.ts
CHANGED
|
@@ -128,7 +128,8 @@ export function splitStatements(sql: string): string[] {
|
|
|
128
128
|
return out.map((x) => x.replace(/^(\s*(--[^\n]*(\n|$)|\/\*[\s\S]*?\*\/))*\s*/, '').trim()).filter(Boolean);
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
/** Replace every :NAME bind (outside strings, comments and casts) with replace(NAME). */
|
|
132
|
+
export function substitute(sql: string, replace: (name: string) => string): string {
|
|
132
133
|
let out = '';
|
|
133
134
|
let i = 0;
|
|
134
135
|
const n = sql.length;
|
package/src/builder/advisor.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type { FastifyInstance } from 'fastify';
|
|
2
2
|
import pg from 'pg';
|
|
3
3
|
import { applyBinds, splitStatements } from '../binds.ts';
|
|
4
|
-
import { owner, type Db } from '../db.ts';
|
|
4
|
+
import { owner, runtime, type Db } from '../db.ts';
|
|
5
5
|
import { devDbForApp } from '../workspace-db.ts';
|
|
6
6
|
import { html, type Raw } from '../html.ts';
|
|
7
7
|
import { icon } from '../icons.ts';
|
|
8
8
|
import { templateProblem } from '../runtime/document.ts';
|
|
9
9
|
import { invokeStepReferences, stepProblems, stepWarnings } from '../workflow.ts';
|
|
10
10
|
import { handlerProblems } from '../runtime/rest.ts';
|
|
11
|
+
import { plpgsqlBlock } from '../runtime/rest-handlers.ts';
|
|
11
12
|
import { processProblems } from '../runtime/processes.ts';
|
|
12
13
|
import { urlConfigProblem, urlTarget } from '../runtime/url-region.ts';
|
|
13
14
|
import { configColumns, searchConfigProblems } from '../runtime/search.ts';
|
|
@@ -249,7 +250,15 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
249
250
|
for (const h of Array.isArray(row.handlers) ? row.handlers : []) {
|
|
250
251
|
if (typeof h?.source !== 'string' || !h.source.trim()) continue;
|
|
251
252
|
checked++;
|
|
252
|
-
|
|
253
|
+
// (117) a PL/pgSQL handler is compiled as the DO block it runs as
|
|
254
|
+
let block: string | null = null;
|
|
255
|
+
if (h.type === 'plpgsql') try {
|
|
256
|
+
block = plpgsqlBlock(h.source);
|
|
257
|
+
} catch (e) {
|
|
258
|
+
findings.push({ severity: 'error', message: (e as Error).message, entry, field: `${h.method} ${h.path}` });
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const problem = await checkSql(c, block ?? h.source, h.type === 'sql' || block ? 'statements' : 'select');
|
|
253
262
|
if (problem) findings.push({ ...problem, entry, field: `${h.method} ${h.path}` });
|
|
254
263
|
}
|
|
255
264
|
}
|
|
@@ -388,6 +397,19 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
388
397
|
}
|
|
389
398
|
}
|
|
390
399
|
|
|
400
|
+
// ---- (117) REST modules refuse (403) to run as a superuser, a role that bypasses RLS or a member of pgkiln's
|
|
401
|
+
// owner: say so here before the endpoints fail. Without a db_role the runtime connection's own user is the role.
|
|
402
|
+
if (all.some((x) => x.kind === 'rest_module')) {
|
|
403
|
+
const role = await (app.db_role ? owner : runtime).one(
|
|
404
|
+
`select r.rolname, r.rolsuper or r.rolbypassrls or pg_has_role(r.oid, n.nspowner, 'MEMBER') as bad
|
|
405
|
+
from pg_roles r, pg_namespace n where r.rolname = coalesce($1, current_user) and n.nspname = 'meta'`,
|
|
406
|
+
[app.db_role ?? null],
|
|
407
|
+
);
|
|
408
|
+
if (!role || role.bad)
|
|
409
|
+
findings.push({ severity: 'error', entry: null, field: 'REST modules',
|
|
410
|
+
message: `The handlers would run as ${role?.rolname ?? app.db_role}, a role that bypasses row level security or owns pgkiln, so every REST module endpoint answers 403. Give the application its own database role (Settings → Security), or set RUNTIME_DATABASE_URL to the least-privilege runtime role.` });
|
|
411
|
+
}
|
|
412
|
+
|
|
391
413
|
// ---- PL/pgSQL functions of the app's schemas, with plpgsql_check when installed
|
|
392
414
|
const plpgsqlCheck = !!(await one(`select 1 as ok from pg_extension where extname = 'plpgsql_check'`));
|
|
393
415
|
if (plpgsqlCheck && app.db_role) {
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
|
+
import { apiRoleProblem } from '../api.ts';
|
|
3
|
+
import { owner } from '../db.ts';
|
|
4
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
5
|
+
import { icon } from '../icons.ts';
|
|
6
|
+
import { AUTOREST_METHODS, describeColumns, keyColumns, MAX_PAGE, qualified, RELKINDS, type RestObject } from '../runtime/autorest.ts';
|
|
7
|
+
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
8
|
+
import { publicUrl } from '../sso.ts';
|
|
9
|
+
import { SYSTEM_SCHEMAS } from './object-browser.ts';
|
|
10
|
+
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
11
|
+
import { splitRoles } from './users.ts';
|
|
12
|
+
import { restNav } from './rest-modules.ts';
|
|
13
|
+
import { currentWorkspace, usableSchemas, workshopDb } from './workspaces.ts';
|
|
14
|
+
|
|
15
|
+
// (sprint 100, Database Actions DA-11 part 1) SQL Workshop › RESTful Services: AutoREST. A developer publishes a
|
|
16
|
+
// table or view of a schema the SQL Workshop may use through an application of the workspace (meta.rest_object,
|
|
17
|
+
// migration 113); src/runtime/autorest.ts serves it at /a/<alias>/autorest/<object>/. The application decides who
|
|
18
|
+
// may call it (its tokens and OAuth clients, App › REST API) and as which database role it runs: never the
|
|
19
|
+
// object's owner or a role that bypasses row level security. This page lists the published objects, enables and
|
|
20
|
+
// changes them, and shows the URLs, cURL and fetch examples and the OpenAPI link.
|
|
21
|
+
|
|
22
|
+
const PAGE = `${BASE}/sql/rest`;
|
|
23
|
+
const ALIAS = /^[a-z][a-z0-9_-]{0,62}$/;
|
|
24
|
+
|
|
25
|
+
interface Rel {
|
|
26
|
+
schema: string;
|
|
27
|
+
name: string;
|
|
28
|
+
kind: string;
|
|
29
|
+
}
|
|
30
|
+
interface WsApp {
|
|
31
|
+
id: number;
|
|
32
|
+
alias: string;
|
|
33
|
+
name: string;
|
|
34
|
+
db_role: string | null;
|
|
35
|
+
}
|
|
36
|
+
type Row = RestObject & { app_alias: string; app_name: string; db_role: string | null };
|
|
37
|
+
|
|
38
|
+
const many = (v: unknown) => ([] as string[]).concat((v as string | string[] | undefined) ?? []);
|
|
39
|
+
const kindLabel = (k: string) => RELKINDS[k] ?? k;
|
|
40
|
+
const autoUrl = (alias: string, o?: string) => `${publicUrl()}/a/${alias}/autorest/${o ? `${o}/` : ''}`;
|
|
41
|
+
|
|
42
|
+
/** The applications of the developer's current workspace. */
|
|
43
|
+
async function workspaceApps(s: Session): Promise<WsApp[]> {
|
|
44
|
+
return (await owner.query<WsApp>(`select id, alias, name, db_role from meta.app a where meta.app_workspace(a.id) = $1 order by lower(name)`, [currentWorkspace(s)])).rows;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The published objects of the current workspace (one, with id). */
|
|
48
|
+
async function rows(s: Session, id?: number): Promise<Row[]> {
|
|
49
|
+
return (await owner.query<Row>(
|
|
50
|
+
`select o.*, a.alias as app_alias, a.name as app_name, a.db_role from meta.rest_object o join meta.app a on a.id = o.app_id
|
|
51
|
+
where meta.app_workspace(a.id) = $1 ${id !== undefined ? 'and o.id = $2' : ''} order by a.alias, o.alias`,
|
|
52
|
+
id !== undefined ? [currentWorkspace(s), id] : [currentWorkspace(s)],
|
|
53
|
+
)).rows;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Tables and views the SQL Workshop's connection may use. */
|
|
57
|
+
async function relations(s: Session): Promise<Rel[]> {
|
|
58
|
+
const db = await workshopDb(s);
|
|
59
|
+
const schemas = (await usableSchemas(db)).filter((x) => !SYSTEM_SCHEMAS.includes(x));
|
|
60
|
+
if (!schemas.length) return [];
|
|
61
|
+
return (await db.query<Rel>(
|
|
62
|
+
`select n.nspname as schema, c.relname as name, c.relkind::text as kind from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
63
|
+
where n.nspname = any($1) and c.relkind in ('r', 'p', 'v', 'm', 'f') and not c.relispartition
|
|
64
|
+
and not (n.nspname = 'public' and c.relname like 'pgkiln\\_%')
|
|
65
|
+
order by 1, 2 limit 5000`, [schemas])).rows;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A relation the workshop may use, by schema and name. */
|
|
69
|
+
async function findRel(s: Session, schema: unknown, name: unknown): Promise<Rel | undefined> {
|
|
70
|
+
if (typeof schema !== 'string' || typeof name !== 'string' || SYSTEM_SCHEMAS.includes(schema)) return undefined;
|
|
71
|
+
return (await relations(s)).find((r) => r.schema === schema && r.name === name);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Why `role` may not serve the relation; null when it may. */
|
|
75
|
+
async function roleProblem(role: string | null, rel: Pick<Rel, 'schema' | 'name'>): Promise<string | null> {
|
|
76
|
+
if (!role) return 'The application has no database role. AutoREST runs as the application\'s role: set one in its settings.';
|
|
77
|
+
const problem = await apiRoleProblem(role);
|
|
78
|
+
if (problem) return `The application's database role: ${problem}`;
|
|
79
|
+
const r = await owner.one<{ owns: boolean }>(
|
|
80
|
+
`select pg_has_role($1, c.relowner, 'MEMBER') as owns from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = $2 and c.relname = $3`,
|
|
81
|
+
[role, rel.schema, rel.name]);
|
|
82
|
+
if (r?.owns) return `"${role}" owns ${rel.schema}.${rel.name} (or is a member of its owner): grants and row level security would not apply. Publish it through an application whose role does not own it.`;
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface Input {
|
|
87
|
+
methods: string[];
|
|
88
|
+
auth: 'token' | 'public';
|
|
89
|
+
roles: string[];
|
|
90
|
+
page_size: number;
|
|
91
|
+
enabled: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The form's settings, or the problems with them. */
|
|
95
|
+
async function readInput(b: Record<string, unknown>, rel: Pick<Rel, 'schema' | 'name' | 'kind'>, role: string | null): Promise<{ input: Input; problems: string[] }> {
|
|
96
|
+
const problems: string[] = [];
|
|
97
|
+
const methods = AUTOREST_METHODS.filter((m) => many(b.methods).includes(m));
|
|
98
|
+
if (!methods.length) problems.push('Choose at least one method.');
|
|
99
|
+
const auth = b.auth === 'public' ? 'public' : b.auth === 'token' ? 'token' : null;
|
|
100
|
+
if (!auth) problems.push('Choose who may call it: a token of the application, or public.');
|
|
101
|
+
const size = Number(b.page_size ?? 25);
|
|
102
|
+
if (!Number.isInteger(size) || size < 1 || size > MAX_PAGE) problems.push(`The page size is 1 to ${MAX_PAGE}.`);
|
|
103
|
+
const cols = role ? await describeColumns(owner, rel.schema, rel.name, role) : [];
|
|
104
|
+
if (rel.kind === 'm' && methods.some((m) => m !== 'GET')) problems.push('A materialized view answers GET only.');
|
|
105
|
+
if (!keyColumns(cols).length && methods.some((m) => m === 'PUT' || m === 'DELETE')) problems.push('PUT and DELETE need a primary key; this object has none.');
|
|
106
|
+
const roles = splitRoles(String(b.roles ?? ''));
|
|
107
|
+
if (roles.some((r) => r.length > 100)) problems.push('Role names are at most 100 characters.');
|
|
108
|
+
return { input: { methods, auth: auth ?? 'token', roles, page_size: size, enabled: b.enabled === undefined ? true : b.enabled === '1' }, problems };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const methodChecks = (selected: string[]) =>
|
|
112
|
+
html`<fieldset class="prop-group"><legend>Methods</legend><div class="form-grid">${AUTOREST_METHODS.map((m) =>
|
|
113
|
+
html`<label class="check"><input type="checkbox" name="methods" value="${m}"${selected.includes(m) ? raw(' checked') : ''}> ${m} <small class="help">${
|
|
114
|
+
{ GET: 'rows and one row', POST: 'insert a row', PUT: 'change a row', DELETE: 'delete a row' }[m]}</small></label>`)}</div></fieldset>`;
|
|
115
|
+
|
|
116
|
+
const authChoice = (auth: string) =>
|
|
117
|
+
html`<fieldset class="prop-group"><legend>Who may call it</legend>
|
|
118
|
+
<label class="check"><input type="radio" name="auth" value="token"${auth !== 'public' ? raw(' checked') : ''}> A bearer token of the application <small class="help">(an account's token or an OAuth client, App › REST API)</small></label>
|
|
119
|
+
<label class="check"><input type="radio" name="auth" value="public"${auth === 'public' ? raw(' checked') : ''}> Public: anyone, without a token <small class="help">(runs as <code>nobody</code>; grants and row level security still apply)</small></label>
|
|
120
|
+
</fieldset>`;
|
|
121
|
+
|
|
122
|
+
const errorList = (problems: string[]) =>
|
|
123
|
+
problems.length ? html`<div class="alert alert-error" role="alert"><ul class="u-m0">${problems.map((p) => html`<li>${p}</li>`)}</ul></div>` : '';
|
|
124
|
+
|
|
125
|
+
/** The list and the Enable form. */
|
|
126
|
+
async function listPage(s: Session, q: Record<string, unknown>, problems: string[] = []) {
|
|
127
|
+
const [list, apps, rels] = await Promise.all([rows(s), workspaceApps(s), relations(s)]);
|
|
128
|
+
const usable = apps.filter((a) => a.db_role);
|
|
129
|
+
const wantedRel = typeof q.schema === 'string' && typeof q.name === 'string' ? `${q.schema}.${q.name}` : '';
|
|
130
|
+
const wantedRelKey = rels.find((r) => `${r.schema}.${r.name}` === wantedRel) ? JSON.stringify([q.schema, q.name]) : '';
|
|
131
|
+
const bySchema = new Map<string, Rel[]>();
|
|
132
|
+
for (const r of rels) bySchema.set(r.schema, [...(bySchema.get(r.schema) ?? []), r]);
|
|
133
|
+
const table = list.length
|
|
134
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Object</th><th>URL</th><th>Application</th><th>Methods</th><th>Access</th></tr></thead><tbody>
|
|
135
|
+
${list.map((o) => html`<tr${o.enabled ? '' : raw(' class="muted"')}>
|
|
136
|
+
<td data-label="Object"><a href="${PAGE}/${o.id}">${o.schema_name}.${o.object_name}</a>${o.enabled ? '' : html` <span class="tag">off</span>`}</td>
|
|
137
|
+
<td data-label="URL" class="wrap-anywhere"><code>/a/${o.app_alias}/autorest/${o.alias}/</code></td>
|
|
138
|
+
<td data-label="Application">${o.app_name}</td>
|
|
139
|
+
<td data-label="Methods">${o.methods.join(', ')}</td>
|
|
140
|
+
<td data-label="Access">${o.auth === 'public' ? html`<strong>public</strong>` : 'token'}${o.roles.length ? `, role ${o.roles.join(' or ')}` : ''}</td></tr>`)}
|
|
141
|
+
</tbody></table></div>`
|
|
142
|
+
: html`<p class="muted">No tables or views are published yet. Enable one below, or with <em>REST › Enable</em> in an object's ⋯ menu.</p>`;
|
|
143
|
+
const form = html`<form method="post" action="${PAGE}" id="enable">${csrf(s)}
|
|
144
|
+
${errorList(problems)}
|
|
145
|
+
<div class="form-grid">
|
|
146
|
+
<div class="field"><label class="label" for="f_object">Table or view</label>
|
|
147
|
+
<select id="f_object" name="object" required><option value="">- choose -</option>${[...bySchema].map(([schema, rs]) =>
|
|
148
|
+
html`<optgroup label="${schema}">${rs.map((r) => {
|
|
149
|
+
const v = JSON.stringify([r.schema, r.name]);
|
|
150
|
+
return html`<option value="${v}"${v === wantedRelKey ? raw(' selected') : ''}>${r.schema}.${r.name}${r.kind === 'r' || r.kind === 'p' ? '' : ` (${kindLabel(r.kind)})`}</option>`;
|
|
151
|
+
})}</optgroup>`)}</select></div>
|
|
152
|
+
<div class="field"><label class="label" for="f_app">Application</label>
|
|
153
|
+
<select id="f_app" name="app_id" required>${usable.map((a) => html`<option value="${a.id}"${String(q.app_id ?? '') === String(a.id) ? raw(' selected') : ''}>${a.name} (${a.alias}, runs as ${a.db_role})</option>`)}</select>
|
|
154
|
+
<small class="help">Its database role runs the statements, its tokens and roles decide who may call it.</small></div>
|
|
155
|
+
<div class="field"><label class="label" for="f_alias">URL name</label>
|
|
156
|
+
<input id="f_alias" name="alias" value="${String(q.alias ?? '')}" placeholder="the object's name">
|
|
157
|
+
<small class="help">/a/<app>/autorest/<b>name</b>/ — lower case letters, digits, _ and -</small></div>
|
|
158
|
+
<div class="field"><label class="label" for="f_roles">Roles</label>
|
|
159
|
+
<input id="f_roles" name="roles" value="${String(q.roles ?? '')}" placeholder="any caller">
|
|
160
|
+
<small class="help">Comma separated: the caller needs one of these application roles.</small></div>
|
|
161
|
+
<div class="field"><label class="label" for="f_page_size">Page size</label>
|
|
162
|
+
<input id="f_page_size" name="page_size" type="number" min="1" max="${MAX_PAGE}" value="${String(q.page_size ?? 25)}">
|
|
163
|
+
<small class="help">Rows per page without ?limit= (at most ${MAX_PAGE}).</small></div>
|
|
164
|
+
</div>
|
|
165
|
+
${methodChecks(many(q.methods).length ? many(q.methods) : ['GET'])}
|
|
166
|
+
${authChoice(String(q.auth ?? 'token'))}
|
|
167
|
+
<div class="buttons"><button class="btn btn-hot"${usable.length ? '' : raw(' disabled')}>${icon('bolt')} Enable</button></div>
|
|
168
|
+
${usable.length ? '' : html`<p class="muted">No application of this workspace has a database role to run as.</p>`}
|
|
169
|
+
</form>`;
|
|
170
|
+
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('rest')}${restNav('autorest')}
|
|
171
|
+
<p class="muted">RESTful Services: publish a table or view as REST endpoints (AutoREST). Rows come paged as JSON, with filters; a primary key gives single-row GET, PUT and DELETE. Requests run as the publishing application's database role with the caller as <code>meta.app_user()</code>, so its grants and row level security apply. For tailored endpoints with your own SQL or PL/pgSQL, create a <a href="${BASE}/sql/rest/modules">module</a>.</p>
|
|
172
|
+
${region('Published objects', table)}
|
|
173
|
+
${region('Enable a table or view', form)}`;
|
|
174
|
+
return shell(s, 'RESTful Services', [['SQL Workshop', `${BASE}/sql`], ['RESTful Services']], main, 'sql');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** One published object: checks, endpoints, examples, settings. */
|
|
178
|
+
async function detailPage(s: Session, o: Row, problems: string[] = []) {
|
|
179
|
+
const rel = { schema: o.schema_name, name: o.object_name };
|
|
180
|
+
const info = await owner.one<{ kind: string; rls: boolean; force: boolean; policies: number; invoker: boolean; oid: number }>(
|
|
181
|
+
`select c.relkind::text as kind, c.relrowsecurity as rls, c.relforcerowsecurity as force, c.oid::int as oid,
|
|
182
|
+
(select count(*) from pg_policy p where p.polrelid = c.oid)::int as policies,
|
|
183
|
+
coalesce(c.reloptions && array['security_invoker=true', 'security_invoker=on', 'security_invoker=1'], false) as invoker
|
|
184
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = $1 and c.relname = $2`, [o.schema_name, o.object_name]);
|
|
185
|
+
const role = o.db_role;
|
|
186
|
+
const problem = info ? await roleProblem(role, rel) : 'The object no longer exists: its endpoints answer 404.';
|
|
187
|
+
const cols = info && role ? await describeColumns(owner, o.schema_name, o.object_name, role) : [];
|
|
188
|
+
const key = keyColumns(cols);
|
|
189
|
+
const privs = info && role && !problem
|
|
190
|
+
? (await owner.one<Record<string, boolean>>(
|
|
191
|
+
`select has_table_privilege($1, $2::oid, 'SELECT') as "GET", has_table_privilege($1, $2::oid, 'INSERT') as "POST",
|
|
192
|
+
has_table_privilege($1, $2::oid, 'UPDATE') or has_any_column_privilege($1, $2::oid, 'UPDATE') as "PUT", has_table_privilege($1, $2::oid, 'DELETE') as "DELETE",
|
|
193
|
+
has_any_column_privilege($1, $2::oid, 'SELECT') as sel`, [role, info.oid]))!
|
|
194
|
+
: null;
|
|
195
|
+
const check = (ok: boolean | null, text: Raw | string) => html`<li>${ok === null ? 'ℹ' : ok ? '✓' : '✗'} ${text}</li>`;
|
|
196
|
+
const priv = { GET: 'select', POST: 'insert', PUT: 'update', DELETE: 'delete' } as Record<string, string>;
|
|
197
|
+
const checks = html`<ul class="checklist">
|
|
198
|
+
${check(!problem, problem ?? html`Runs as <code>${role}</code>: not the owner, no superuser, no row level security bypass`)}
|
|
199
|
+
${privs ? o.methods.map((m) => check(m === 'GET' ? privs.GET || privs.sel : privs[m], privs[m] || (m === 'GET' && privs.sel)
|
|
200
|
+
? html`${m}: <code>${role}</code> may ${priv[m]}`
|
|
201
|
+
: html`${m}: <code>${role}</code> may not ${priv[m]} — <code>grant ${priv[m]} on ${qualified(o)} to ${role};</code>`)) : ''}
|
|
202
|
+
${info && (info.kind === 'r' || info.kind === 'p') ? check(info.rls ? true : null, info.rls ? `Row level security is on (${info.policies} polic${info.policies === 1 ? 'y' : 'ies'})` : 'Row level security is off: callers see every row the role may select') : ''}
|
|
203
|
+
${info && info.kind === 'v' ? check(info.invoker, info.invoker ? 'The view is security_invoker: the tables\' row level security applies to the caller' : html`The view runs with its owner's rights, so the row level security of the tables beneath does not apply: <code>alter view ${qualified(o)} set (security_invoker = true);</code>`) : ''}
|
|
204
|
+
${info ? check(key.length ? true : null, key.length ? html`Primary key ${key.map((k) => k.name).join(', ')}: single rows at <code>…/${o.alias}/<${key.map((k) => k.name).join(',')}></code>` : 'No primary key: the collection only (GET and POST)') : ''}
|
|
205
|
+
${cols.some((c) => !c.readable) ? check(null, `Columns the role may not select are left out: ${cols.filter((c) => !c.readable).map((c) => c.name).join(', ')}`) : ''}
|
|
206
|
+
</ul>`;
|
|
207
|
+
const base = autoUrl(o.app_alias, o.alias);
|
|
208
|
+
const keyEx = key.map((k) => `<${k.name}>`).join(',');
|
|
209
|
+
const endpoints: [string, string, string][] = [];
|
|
210
|
+
if (o.methods.includes('GET')) endpoints.push(['GET', base, `Rows, paged: ?limit= (1–${MAX_PAGE}, default ${o.page_size}) &offset= ; ?<column>=<value> ; ?q=<text> ; ?order=<column>.desc`]);
|
|
211
|
+
if (o.methods.includes('POST')) endpoints.push(['POST', base, 'A JSON object of column values: the new row (201)']);
|
|
212
|
+
if (key.length) {
|
|
213
|
+
if (o.methods.includes('GET')) endpoints.push(['GET', `${base}${keyEx}`, 'One row (404 when there is none)']);
|
|
214
|
+
if (o.methods.includes('PUT')) endpoints.push(['PUT', `${base}${keyEx}`, 'Change the columns the JSON object names']);
|
|
215
|
+
if (o.methods.includes('DELETE')) endpoints.push(['DELETE', `${base}${keyEx}`, 'Delete the row (204)']);
|
|
216
|
+
}
|
|
217
|
+
const auth = o.auth === 'public' ? '' : ' -H "Authorization: Bearer $TOKEN"';
|
|
218
|
+
const sample = cols.filter((c) => c.readable && c.pk === null).slice(0, 2);
|
|
219
|
+
const body = JSON.stringify(Object.fromEntries(sample.map((c) => [c.name, c.cat === 'N' ? 1 : c.cat === 'B' ? true : '…'])));
|
|
220
|
+
const textCol = cols.find((c) => c.readable && c.cat === 'S');
|
|
221
|
+
const curl = [
|
|
222
|
+
o.methods.includes('GET') ? `curl "${base}?limit=10${textCol ? '&q=a' : ''}"${auth}` : '',
|
|
223
|
+
o.methods.includes('GET') && key.length ? `curl ${base}1${auth}` : '',
|
|
224
|
+
o.methods.includes('POST') ? `curl -X POST ${base}${auth} -H "Content-Type: application/json" -d '${body}'` : '',
|
|
225
|
+
o.methods.includes('PUT') && key.length ? `curl -X PUT ${base}1${auth} -H "Content-Type: application/json" -d '${body}'` : '',
|
|
226
|
+
o.methods.includes('DELETE') && key.length ? `curl -X DELETE ${base}1${auth}` : '',
|
|
227
|
+
].filter(Boolean).join('\n');
|
|
228
|
+
const fetchEx = `const res = await fetch('${base}?limit=10'${o.auth === 'public' ? '' : `, {\n headers: { Authorization: \`Bearer \${token}\` },\n}`});\nconst { items, has_more } = await res.json();`;
|
|
229
|
+
const openapi = `${publicUrl()}/a/${o.app_alias}/autorest/openapi.json`;
|
|
230
|
+
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('rest')}
|
|
231
|
+
<h2 class="u-mb1 wrap-anywhere">${o.schema_name}.${o.object_name} <span class="muted small">${info ? kindLabel(info.kind) : ''} · ${o.enabled ? 'enabled' : 'disabled'}</span></h2>
|
|
232
|
+
${errorList(problems)}
|
|
233
|
+
${!o.enabled ? html`<div class="alert alert-info" role="status">Disabled: the endpoints answer 404.</div>` : ''}
|
|
234
|
+
<div class="columns">
|
|
235
|
+
${region('Checks', checks)}
|
|
236
|
+
${region('Endpoints', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Method</th><th>URL</th><th>Does</th></tr></thead><tbody>
|
|
237
|
+
${endpoints.map(([m, u, d]) => html`<tr><td data-label="Method"><code>${m}</code></td><td data-label="URL" class="wrap-anywhere"><code>${u}</code></td><td data-label="Does">${d}</td></tr>`)}</tbody></table></div>
|
|
238
|
+
<p class="wrap-anywhere">OpenAPI: <a href="${openapi}" target="_blank" rel="noopener"><code>${openapi}</code></a> <span class="muted small">(the public objects; send a token for all of them)</span></p>`)}
|
|
239
|
+
</div>
|
|
240
|
+
${region('Try it', html`${o.auth === 'public'
|
|
241
|
+
? html`<p class="muted u-mt0">Public: no token needed.</p>`
|
|
242
|
+
: html`<p class="muted u-mt0">Get <code>$TOKEN</code> from <a href="${BASE}/apps/${o.app_id}/api">${o.app_name} › REST API</a>: issue a token for an account, or use an OAuth client's access token.${o.roles.length ? ` The caller needs the role ${o.roles.join(' or ')}.` : ''} Without a valid token the endpoints answer 401; without the role or a grant, 403.</p>`}
|
|
243
|
+
<h3 class="small">cURL</h3><pre class="source wrap-pre">${curl}</pre>
|
|
244
|
+
<h3 class="small">JavaScript (fetch)</h3><pre class="source wrap-pre">${fetchEx}</pre>`)}
|
|
245
|
+
${region('Settings', html`<form method="post" action="${PAGE}/${o.id}">${csrf(s)}
|
|
246
|
+
<p class="muted u-mt0 wrap-anywhere">Published by <a href="${BASE}/apps/${o.app_id}">${o.app_name}</a> (runs as <code>${role ?? '—'}</code>) at <code>/a/${o.app_alias}/autorest/${o.alias}/</code>.</p>
|
|
247
|
+
<div class="form-grid">
|
|
248
|
+
<div class="field"><label class="label" for="f_roles">Roles</label><input id="f_roles" name="roles" value="${o.roles.join(', ')}" placeholder="any caller"><small class="help">Comma separated: the caller needs one of these application roles.</small></div>
|
|
249
|
+
<div class="field"><label class="label" for="f_page_size">Page size</label><input id="f_page_size" name="page_size" type="number" min="1" max="${MAX_PAGE}" value="${o.page_size}"></div>
|
|
250
|
+
<div class="field"><label class="label" for="f_enabled">Status</label><select id="f_enabled" name="enabled"><option value="1"${o.enabled ? raw(' selected') : ''}>Enabled</option><option value="0"${o.enabled ? '' : raw(' selected')}>Disabled (404)</option></select></div>
|
|
251
|
+
</div>
|
|
252
|
+
${methodChecks(o.methods)}
|
|
253
|
+
${authChoice(o.auth)}
|
|
254
|
+
<div class="buttons"><button class="btn btn-hot">Save</button></div>
|
|
255
|
+
</form>
|
|
256
|
+
<form method="post" action="${PAGE}/${o.id}/delete" class="u-mt1">${csrf(s)}
|
|
257
|
+
<button class="btn btn-danger">${icon('trash')} Disable REST (remove)</button> <span class="muted small">The endpoints answer 404 at once; the table itself is not changed.</span>
|
|
258
|
+
</form>`)}`;
|
|
259
|
+
return shell(s, `REST ${o.schema_name}.${o.object_name}`, [['SQL Workshop', `${BASE}/sql`], ['RESTful Services', PAGE], [`${o.schema_name}.${o.object_name}`]], main, 'sql');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const parseObject = (v: unknown): [string, string] | null => {
|
|
263
|
+
try {
|
|
264
|
+
const x = JSON.parse(String(v ?? ''));
|
|
265
|
+
return Array.isArray(x) && x.length === 2 && x.every((y) => typeof y === 'string') ? [x[0], x[1]] : null;
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
async function rowOr404(req: Req, reply: FastifyReply, s: Session) {
|
|
272
|
+
const id = Number(req.params.id);
|
|
273
|
+
const o = Number.isInteger(id) && id > 0 ? (await rows(s, id))[0] : undefined;
|
|
274
|
+
if (!o) reply.code(404).send('Not found');
|
|
275
|
+
return o;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function autoRestBuilderRoutes(app: FastifyInstance) {
|
|
279
|
+
app.get(PAGE, async (req: Req, reply) => {
|
|
280
|
+
const s = await developer(req, reply);
|
|
281
|
+
if (!s) return;
|
|
282
|
+
return send(reply, s, await listPage(s, req.query ?? {}));
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// an object's ⋯ menu (REST › Enable / Disable): its page when it is published, else the Enable form for it
|
|
286
|
+
app.get(`${PAGE}/object`, async (req: Req, reply) => {
|
|
287
|
+
const s = await developer(req, reply);
|
|
288
|
+
if (!s) return;
|
|
289
|
+
const q = req.query ?? {};
|
|
290
|
+
const rel = await findRel(s, q.schema, q.name);
|
|
291
|
+
if (!rel) return reply.code(404).send('Not found');
|
|
292
|
+
const hit = (await rows(s)).find((o) => o.schema_name === rel.schema && o.object_name === rel.name);
|
|
293
|
+
return reply.redirect(hit ? `${PAGE}/${hit.id}` : `${PAGE}?${new URLSearchParams({ schema: rel.schema, name: rel.name })}#enable`);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
app.post(PAGE, async (req: Req, reply) => {
|
|
297
|
+
const s = await developer(req, reply);
|
|
298
|
+
if (!s) return;
|
|
299
|
+
const b = (req.body ?? {}) as Record<string, unknown>;
|
|
300
|
+
const pick = parseObject(b.object);
|
|
301
|
+
const rel = pick ? await findRel(s, pick[0], pick[1]) : undefined;
|
|
302
|
+
const again = async (problems: string[]) =>
|
|
303
|
+
send(reply.code(200), s, await listPage(s, { ...b, ...(rel ? { schema: rel.schema, name: rel.name } : {}) }, problems));
|
|
304
|
+
if (!rel) return again(['Choose a table or view of a schema the SQL Workshop may use.']);
|
|
305
|
+
const a = (await workspaceApps(s)).find((x) => String(x.id) === String(b.app_id ?? ''));
|
|
306
|
+
if (!a) return again(['Choose an application of this workspace.']);
|
|
307
|
+
const alias = String(b.alias ?? '').trim() || rel.name.toLowerCase().replace(/[^a-z0-9_-]+/g, '_').replace(/^[^a-z]+/, '').slice(0, 63);
|
|
308
|
+
const problems: string[] = [];
|
|
309
|
+
if (!ALIAS.test(alias)) problems.push('The URL name starts with a lower case letter: letters, digits, _ and - (at most 63).');
|
|
310
|
+
const rp = await roleProblem(a.db_role, rel);
|
|
311
|
+
if (rp) problems.push(rp);
|
|
312
|
+
const { input, problems: more } = await readInput(b, rel, a.db_role);
|
|
313
|
+
problems.push(...more);
|
|
314
|
+
if (problems.length) return again(problems);
|
|
315
|
+
try {
|
|
316
|
+
const r = await owner.one<{ id: number }>(
|
|
317
|
+
`insert into meta.rest_object (app_id, schema_name, object_name, alias, methods, auth, roles, page_size, created_by)
|
|
318
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8, $9) returning id`,
|
|
319
|
+
[a.id, rel.schema, rel.name, alias, input.methods, input.auth, input.roles, input.page_size, s.username]);
|
|
320
|
+
await logActivity({ appId: a.id, username: s.username, event: 'autorest', ip: clientIp(req), detail: `enabled ${rel.schema}.${rel.name} as ${alias} (${input.methods.join(', ')}, ${input.auth})` });
|
|
321
|
+
flash(s, `${rel.schema}.${rel.name} is published at /a/${a.alias}/autorest/${alias}/.`);
|
|
322
|
+
return back(reply, s, `${PAGE}/${r!.id}`);
|
|
323
|
+
} catch (e) {
|
|
324
|
+
const err = e as { code?: string; constraint?: string; message: string };
|
|
325
|
+
if (err.code !== '23505') throw e;
|
|
326
|
+
return again([/alias/.test(err.constraint ?? '') ? `${a.name} already publishes something as ${alias}: choose another URL name.` : `${a.name} already publishes ${rel.schema}.${rel.name}.`]);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
app.get(`${PAGE}/:id`, async (req: Req, reply) => {
|
|
331
|
+
const s = await developer(req, reply);
|
|
332
|
+
if (!s) return;
|
|
333
|
+
const o = await rowOr404(req, reply, s);
|
|
334
|
+
if (!o) return;
|
|
335
|
+
return send(reply, s, await detailPage(s, o));
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
app.post(`${PAGE}/:id`, async (req: Req, reply) => {
|
|
339
|
+
const s = await developer(req, reply);
|
|
340
|
+
if (!s) return;
|
|
341
|
+
const o = await rowOr404(req, reply, s);
|
|
342
|
+
if (!o) return;
|
|
343
|
+
const kind = (await owner.one<{ k: string }>(`select c.relkind::text as k from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = $1 and c.relname = $2`, [o.schema_name, o.object_name]))?.k ?? 'r';
|
|
344
|
+
const { input, problems } = await readInput((req.body ?? {}) as Record<string, unknown>, { schema: o.schema_name, name: o.object_name, kind }, o.db_role);
|
|
345
|
+
if (problems.length) return send(reply, s, await detailPage(s, o, problems));
|
|
346
|
+
await owner.query(`update meta.rest_object set methods = $2, auth = $3, roles = $4, page_size = $5, enabled = $6, updated_at = now() where id = $1`,
|
|
347
|
+
[o.id, input.methods, input.auth, input.roles, input.page_size, input.enabled]);
|
|
348
|
+
await logActivity({ appId: o.app_id, username: s.username, event: 'autorest', ip: clientIp(req), detail: `changed ${o.schema_name}.${o.object_name} (${input.methods.join(', ')}, ${input.auth}${input.enabled ? '' : ', disabled'})` });
|
|
349
|
+
flash(s, 'Saved.');
|
|
350
|
+
return back(reply, s, `${PAGE}/${o.id}`);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
app.post(`${PAGE}/:id/delete`, async (req: Req, reply) => {
|
|
354
|
+
const s = await developer(req, reply);
|
|
355
|
+
if (!s) return;
|
|
356
|
+
const o = await rowOr404(req, reply, s);
|
|
357
|
+
if (!o) return;
|
|
358
|
+
await owner.query('delete from meta.rest_object where id = $1', [o.id]);
|
|
359
|
+
await logActivity({ appId: o.app_id, username: s.username, event: 'autorest', ip: clientIp(req), detail: `disabled ${o.schema_name}.${o.object_name}` });
|
|
360
|
+
flash(s, `${o.schema_name}.${o.object_name} is no longer published.`);
|
|
361
|
+
return back(reply, s, PAGE);
|
|
362
|
+
});
|
|
363
|
+
}
|