pgkiln 0.82.0 → 0.83.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/db/migrations/153_component_events.sql +21 -0
- package/docs/apex-feature-parity.md +3 -3
- package/docs/guide/03-builder.md +8 -1
- package/docs/guide/04-pages-and-regions.md +2 -1
- package/docs/guide/07-dynamic-actions.md +27 -0
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +9 -1
- package/package.json +1 -1
- package/public/app.css +2 -0
- package/public/app.js +85 -4
- package/public/builder.css +25 -2
- package/public/data-modeler.js +8 -5
- package/public/sql-monitor.js +5 -2
- package/src/builder/ai-builder.ts +50 -46
- package/src/builder/api.ts +49 -49
- package/src/builder/autorest.ts +92 -87
- package/src/builder/changelog.ts +39 -37
- package/src/builder/components.ts +5 -4
- package/src/builder/data-modeler.ts +89 -84
- package/src/builder/data-security.ts +39 -37
- package/src/builder/dataload.ts +69 -65
- package/src/builder/datapump.ts +108 -97
- package/src/builder/ddl-tools.ts +38 -34
- package/src/builder/document-view.ts +11 -11
- package/src/builder/i18n/de.ts +1627 -0
- package/src/builder/i18n/en.ts +1627 -0
- package/src/builder/i18n/es.ts +1627 -0
- package/src/builder/i18n/fr.ts +1627 -0
- package/src/builder/i18n/it.ts +1627 -0
- package/src/builder/i18n/ja.ts +1627 -0
- package/src/builder/i18n/ko.ts +1627 -0
- package/src/builder/i18n/nl.ts +1627 -0
- package/src/builder/i18n/pt.ts +1627 -0
- package/src/builder/i18n/zh-cn.ts +1627 -0
- package/src/builder/i18n/zh-tw.ts +1627 -0
- package/src/builder/i18n.ts +18 -0
- package/src/builder/object-actions.ts +18 -16
- package/src/builder/object-browser.ts +2 -2
- package/src/builder/querybuilder.ts +42 -36
- package/src/builder/quicksql.ts +16 -18
- package/src/builder/region-settings.ts +201 -199
- package/src/builder/report-settings.ts +64 -60
- package/src/builder/rest-modules.ts +145 -143
- package/src/builder/sampledata.ts +59 -52
- package/src/builder/scheduling.ts +180 -159
- package/src/builder/scripts.ts +71 -65
- package/src/builder/sql-charts.ts +36 -30
- package/src/builder/sql-monitor.ts +95 -89
- package/src/builder/sql-utilities.ts +47 -40
- package/src/builder/top-sql.ts +21 -17
- package/src/builder/unload.ts +48 -44
- package/src/builder/worksheet.ts +3 -3
- package/src/cron-schedule.ts +47 -17
- package/src/metadata.ts +3 -1
- package/src/runtime/calendar.ts +3 -2
- package/src/runtime/context.ts +2 -0
- package/src/runtime/grid.ts +1 -0
- package/src/runtime/maps.ts +7 -4
- package/src/runtime/render.ts +6 -1
- package/src/runtime/routes.ts +4 -0
- package/src/runtime/tree.ts +4 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
2
|
import { apiRoleProblem, issueApiToken } from '../api.ts';
|
|
3
3
|
import { owner } from '../db.ts';
|
|
4
|
-
import { html, raw, type Raw } from '../html.ts';
|
|
4
|
+
import { esc, html, raw, type Raw } from '../html.ts';
|
|
5
5
|
import { icon } from '../icons.ts';
|
|
6
6
|
import { handlerProblems, METHODS, PARAM_TYPES, templateProblems, type Handler, type HandlerParam, type Template } from '../runtime/rest.ts';
|
|
7
7
|
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
@@ -10,6 +10,7 @@ import { lockText } from './locks.ts';
|
|
|
10
10
|
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
11
11
|
import { splitRoles } from './users.ts';
|
|
12
12
|
import { currentWorkspace } from './workspaces.ts';
|
|
13
|
+
import { scopedTx as tx, tr, translatorFor, withTranslator, type BuilderTranslate } from './i18n.ts';
|
|
13
14
|
|
|
14
15
|
// (sprint 104, Database Actions DA-11 part 2; migration 117) SQL Workshop › RESTful Services › Modules: an
|
|
15
16
|
// application's REST modules (meta.rest_module, served by runtime/rest.ts) edited the ORDS way, as a tree of
|
|
@@ -22,13 +23,8 @@ import { currentWorkspace } from './workspaces.ts';
|
|
|
22
23
|
|
|
23
24
|
export const MODULES = `${BASE}/sql/rest/modules`;
|
|
24
25
|
const NAME = /^[a-z][a-z0-9_-]{0,39}$/;
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
item: 'Single row: an object, 404 without a row',
|
|
28
|
-
sql: 'SQL: the first row of the last statement',
|
|
29
|
-
plpgsql: 'PL/pgSQL block: meta.rest_reply(body, status)',
|
|
30
|
-
media: 'Media resource: content_type and content',
|
|
31
|
-
};
|
|
26
|
+
const TYPES = ['collection', 'item', 'sql', 'plpgsql', 'media'] as const;
|
|
27
|
+
const typeLabel = (k: string) => ((TYPES as readonly string[]).includes(k) ? tx(`rm.type.${k as (typeof TYPES)[number]}`) : k);
|
|
32
28
|
const MAX_TRY_BODY = 20_000;
|
|
33
29
|
|
|
34
30
|
interface ModRow {
|
|
@@ -48,9 +44,9 @@ interface ModRow {
|
|
|
48
44
|
|
|
49
45
|
/** RESTful Services' two parts as tabs: Modules and AutoREST. */
|
|
50
46
|
export const restNav = (active: 'modules' | 'autorest') =>
|
|
51
|
-
html`<nav class="settings-tabs" aria-label="
|
|
52
|
-
<a href="${MODULES}"${active === 'modules' ? raw(' aria-current="page"') : ''}
|
|
53
|
-
<a href="${BASE}/sql/rest"${active === 'autorest' ? raw(' aria-current="page"') : ''}
|
|
47
|
+
html`<nav class="settings-tabs" aria-label="${tx('sql.tool.rest')}">
|
|
48
|
+
<a href="${MODULES}"${active === 'modules' ? raw(' aria-current="page"') : ''}>${tx('rm.modules')}</a>
|
|
49
|
+
<a href="${BASE}/sql/rest"${active === 'autorest' ? raw(' aria-current="page"') : ''}>${tx('rm.autorest_tab')}</a></nav>`;
|
|
54
50
|
|
|
55
51
|
/** The modules of the current workspace's applications (one, with id). */
|
|
56
52
|
async function modules(s: Session, id?: number): Promise<ModRow[]> {
|
|
@@ -68,9 +64,9 @@ async function workspaceApps(s: Session) {
|
|
|
68
64
|
/** Why the application can't be changed now (Run only, or locked by another developer), or null. */
|
|
69
65
|
async function changeRefused(s: Session, appId: number): Promise<string | null> {
|
|
70
66
|
const a = await owner.one<{ run_only: boolean }>(`select build_status = 'run_only' as run_only from meta.app where id = $1`, [appId]);
|
|
71
|
-
if (a?.run_only) return `${lockText({ page_no: 0, locked_by: '', locked_at: '', note: null, run_only: true })}
|
|
67
|
+
if (a?.run_only) return `${lockText({ page_no: 0, locked_by: '', locked_at: '', note: null, run_only: true })} ${tr(s)('rm.run_only_suffix')}`;
|
|
72
68
|
const lock = await owner.one(`select page_no, locked_by, locked_at::text, note from meta.builder_lock where app_id = $1 and page_no = 0 and locked_by <> $2`, [appId, s.username]);
|
|
73
|
-
return lock ? `${lockText(lock)}
|
|
69
|
+
return lock ? `${lockText(lock)} ${tr(s)('rm.locked_suffix')}` : null;
|
|
74
70
|
}
|
|
75
71
|
|
|
76
72
|
/** The module's templates: the declared ones and every handler's path, sorted. */
|
|
@@ -86,13 +82,14 @@ const q = (v: Record<string, string>) => new URLSearchParams(v).toString();
|
|
|
86
82
|
const tplUrl = (m: ModRow, uri: string) => `${MODULES}/${m.id}/template?${q({ t: uri })}`;
|
|
87
83
|
const handlerUrl = (m: ModRow, h: Pick<Handler, 'path' | 'method'>) => `${MODULES}/${m.id}/handler?${q({ t: h.path, m: h.method })}`;
|
|
88
84
|
const baseUrl = (m: Pick<ModRow, 'app_alias' | 'name'>) => `${publicUrl()}/a/${m.app_alias}/rest/${m.name}/`;
|
|
85
|
+
const anyOf = (roles: string[]) => roles.join(` ${tx('rm.or')} `);
|
|
89
86
|
const access = (m: ModRow, h: Handler) =>
|
|
90
|
-
[h.auth === 'public' ? 'public' : 'token', m.roles.length ?
|
|
87
|
+
[h.auth === 'public' ? tx('rm.public') : tx('rm.token'), m.roles.length ? tx('rm.module_role', { roles: anyOf(m.roles) }) : '', h.roles?.length ? tx('rm.role', { roles: anyOf(h.roles) }) : ''].filter(Boolean).join(', ');
|
|
91
88
|
|
|
92
89
|
/** The module's tree: module › templates › handlers. */
|
|
93
90
|
function tree(m: ModRow, sel: { t?: string; m?: string } = {}) {
|
|
94
91
|
const here = (on: boolean) => (on ? raw(' aria-current="page"') : '');
|
|
95
|
-
return html`<nav aria-label="
|
|
92
|
+
return html`<nav aria-label="${tx('rm.tree_aria')}" class="rm-tree"><ul class="tree">
|
|
96
93
|
<li><a href="${MODULES}/${m.id}"${here(sel.t === undefined)}>${icon('folder')}<span>${m.name}/</span></a>
|
|
97
94
|
<ul>${templatesOf(m).map((t) => html`<li><a href="${tplUrl(m, t.uri)}"${here(sel.t === t.uri && !sel.m)}>${icon('chevron')}<span>${t.uri || '/'}</span></a>
|
|
98
95
|
<ul>${m.handlers.filter((h) => h.path === t.uri).map((h) => html`<li><a href="${handlerUrl(m, h)}"${here(sel.t === t.uri && sel.m === h.method)}><code>${h.method}</code><span>${h.type}</span></a></li>`)}</ul></li>`)}
|
|
@@ -101,45 +98,46 @@ function tree(m: ModRow, sel: { t?: string; m?: string } = {}) {
|
|
|
101
98
|
|
|
102
99
|
/** The page frame: tabs, the module's tree beside the content. */
|
|
103
100
|
function frame(s: Session, m: ModRow, title: string, crumbs: [string, string?][], sel: { t?: string; m?: string }, body: Raw) {
|
|
104
|
-
const main = html`<h1 class="u-mb1"
|
|
105
|
-
<h2 class="u-mb1 wrap-anywhere">${m.title} <span class="muted small">${m.app_name} · /a/${m.app_alias}/rest/${m.name}/${m.enabled ? '' :
|
|
106
|
-
<div class="rm-layout">${region('
|
|
107
|
-
return shell(s, title, [['
|
|
101
|
+
const main = html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('rest', s)}${restNav('modules')}
|
|
102
|
+
<h2 class="u-mb1 wrap-anywhere">${m.title} <span class="muted small">${m.app_name} · /a/${m.app_alias}/rest/${m.name}/${m.enabled ? '' : ` · ${tx('rm.disabled_suffix')}`}</span></h2>
|
|
103
|
+
<div class="rm-layout">${region(tx('rm.module'), tree(m, sel))}<div class="rm-main">${body}</div></div>`;
|
|
104
|
+
return shell(s, title, [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.rest'), MODULES], ...crumbs], main, 'sql');
|
|
108
105
|
}
|
|
109
106
|
|
|
110
107
|
// ------------------------------------------------------------------ pages
|
|
111
108
|
|
|
112
|
-
|
|
109
|
+
const listPage = (s: Session, b: Record<string, unknown> = {}, problems: string[] = []) => withTranslator(tr(s), () => listPageInner(s, b, problems));
|
|
110
|
+
async function listPageInner(s: Session, b: Record<string, unknown>, problems: string[]) {
|
|
113
111
|
const [list, apps] = await Promise.all([modules(s), workspaceApps(s)]);
|
|
114
112
|
const table = list.length
|
|
115
|
-
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th
|
|
113
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('rm.module')}</th><th>${tx('rm.base_path')}</th><th>${tx('rm.application')}</th><th>${tx('rm.templates')}</th><th>${tx('rm.handlers')}</th><th>${tx('rm.privilege')}</th></tr></thead><tbody>
|
|
116
114
|
${list.map((m) => html`<tr${m.enabled ? '' : raw(' class="muted"')}>
|
|
117
|
-
<td data-label="
|
|
118
|
-
<td data-label="
|
|
119
|
-
<td data-label="
|
|
120
|
-
<td data-label="
|
|
121
|
-
<td data-label="
|
|
122
|
-
<td data-label="
|
|
115
|
+
<td data-label="${tx('rm.module')}"><a href="${MODULES}/${m.id}">${m.title}</a>${m.enabled ? '' : html` <span class="tag">${tx('rm.off')}</span>`}</td>
|
|
116
|
+
<td data-label="${tx('rm.base_path')}" class="wrap-anywhere"><code>/a/${m.app_alias}/rest/${m.name}/</code></td>
|
|
117
|
+
<td data-label="${tx('rm.application')}">${m.app_name}</td>
|
|
118
|
+
<td data-label="${tx('rm.templates')}">${templatesOf(m).length}</td>
|
|
119
|
+
<td data-label="${tx('rm.handlers')}">${Array.isArray(m.handlers) ? m.handlers.length : 0}</td>
|
|
120
|
+
<td data-label="${tx('rm.privilege')}">${m.roles.length ? tx('rm.role', { roles: anyOf(m.roles) }) : tx('rm.any_token')}</td></tr>`)}
|
|
123
121
|
</tbody></table></div>`
|
|
124
|
-
: html`<p class="muted"
|
|
122
|
+
: html`<p class="muted">${tx('rm.none_yet')}</p>`;
|
|
125
123
|
const v = (k: string) => String(b[k] ?? '');
|
|
126
124
|
const form = html`<form method="post" action="${MODULES}" id="create">${csrf(s)}${errorList(problems)}
|
|
127
125
|
<div class="form-grid">
|
|
128
|
-
<div class="field"><label class="label" for="f_app"
|
|
126
|
+
<div class="field"><label class="label" for="f_app">${tx('rm.application')}</label>
|
|
129
127
|
<select id="f_app" name="app_id" required>${apps.map((a) => html`<option value="${a.id}"${v('app_id') === String(a.id) ? raw(' selected') : ''}>${a.name} (${a.alias})</option>`)}</select>
|
|
130
|
-
<small class="help"
|
|
131
|
-
<div class="field"><label class="label" for="f_name"
|
|
132
|
-
<small class="help">/a/<app>/rest/<b>name</b>/ —
|
|
133
|
-
<div class="field"><label class="label" for="f_title"
|
|
134
|
-
<div class="field"><label class="label" for="f_roles"
|
|
135
|
-
<small class="help"
|
|
128
|
+
<small class="help">${tx('rm.app_help')}</small></div>
|
|
129
|
+
<div class="field"><label class="label" for="f_name">${tx('rm.base_path')}</label><input id="f_name" name="name" required value="${v('name')}" placeholder="${tx('rm.eg', { example: 'sales' })}">
|
|
130
|
+
<small class="help">/a/<app>/rest/<b>name</b>/ — ${tx('rm.base_path_rule')}</small></div>
|
|
131
|
+
<div class="field"><label class="label" for="f_title">${tx('rm.title')}</label><input id="f_title" name="title" required value="${v('title')}" placeholder="${tx('rm.eg_sales_api')}"></div>
|
|
132
|
+
<div class="field"><label class="label" for="f_roles">${tx('rm.privilege_roles')}</label><input id="f_roles" name="roles" value="${v('roles')}" placeholder="${tx('rm.any_caller')}">
|
|
133
|
+
<small class="help">${tx('rm.roles_help_app')}</small></div>
|
|
136
134
|
</div>
|
|
137
|
-
<div class="field"><label class="label" for="f_description"
|
|
138
|
-
<div class="buttons"><button class="btn btn-hot"${apps.length ? '' : raw(' disabled')}>${icon('plus')}
|
|
139
|
-
const main = html`<h1 class="u-mb1"
|
|
140
|
-
<p class="muted"
|
|
141
|
-
${region('
|
|
142
|
-
return shell(s, '
|
|
135
|
+
<div class="field"><label class="label" for="f_description">${tx('rm.description')}</label><input id="f_description" name="description" value="${v('description')}"></div>
|
|
136
|
+
<div class="buttons"><button class="btn btn-hot"${apps.length ? '' : raw(' disabled')}>${icon('plus')} ${tx('rm.create_module')}</button></div></form>`;
|
|
137
|
+
const main = html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('rest', s)}${restNav('modules')}
|
|
138
|
+
<p class="muted">${tx.code('rm.intro')}</p>
|
|
139
|
+
${region(tx('rm.modules'), table)}${region(tx('rm.create_a_module'), form)}`;
|
|
140
|
+
return shell(s, tx('rm.list_title'), [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.rest'), `${BASE}/sql/rest`], [tx('rm.modules')]], main, 'sql');
|
|
143
141
|
}
|
|
144
142
|
|
|
145
143
|
/** Checks of the role the handlers run as. */
|
|
@@ -147,74 +145,76 @@ async function roleChecks(m: ModRow) {
|
|
|
147
145
|
const problem = m.db_role ? await apiRoleProblem(m.db_role) : null;
|
|
148
146
|
return html`<ul class="checklist">
|
|
149
147
|
<li>${problem ? '✗' : m.db_role ? '✓' : 'ℹ'} ${problem
|
|
150
|
-
?
|
|
151
|
-
: m.db_role ?
|
|
152
|
-
<li>${m.enabled ? '✓' : '✗'} ${m.enabled ? '
|
|
153
|
-
<li>ℹ ${m.roles.length ?
|
|
148
|
+
? tx('rm.chk.role_problem', { problem })
|
|
149
|
+
: m.db_role ? tx.code('rm.chk.runs_as', { role: m.db_role }) : tx('rm.chk.no_role')}</li>
|
|
150
|
+
<li>${m.enabled ? '✓' : '✗'} ${m.enabled ? tx('rm.enabled') : tx('rm.chk.disabled')}</li>
|
|
151
|
+
<li>ℹ ${m.roles.length ? tx('rm.chk.privilege', { roles: anyOf(m.roles) }) : tx('rm.chk.no_privilege')}</li>
|
|
154
152
|
</ul>`;
|
|
155
153
|
}
|
|
156
154
|
|
|
157
|
-
|
|
155
|
+
const modulePage = (s: Session, m: ModRow, problems: string[] = [], b?: Record<string, unknown>) => withTranslator(tr(s), () => modulePageInner(s, m, problems, b));
|
|
156
|
+
async function modulePageInner(s: Session, m: ModRow, problems: string[], b?: Record<string, unknown>) {
|
|
158
157
|
const v = (k: keyof ModRow & string, d: unknown) => String(b?.[k] ?? d ?? '');
|
|
159
158
|
const openapi = `${baseUrl(m)}openapi.json`;
|
|
160
159
|
const settings = html`<form method="post" action="${MODULES}/${m.id}">${csrf(s)}${errorList(problems)}
|
|
161
160
|
<div class="form-grid">
|
|
162
|
-
<div class="field"><label class="label" for="f_name"
|
|
163
|
-
<div class="field"><label class="label" for="f_title"
|
|
164
|
-
<div class="field"><label class="label" for="f_roles"
|
|
165
|
-
<div class="field"><label class="label" for="f_enabled"
|
|
161
|
+
<div class="field"><label class="label" for="f_name">${tx('rm.base_path')}</label><input id="f_name" name="name" required value="${v('name', m.name)}"><small class="help">/a/${m.app_alias}/rest/<b>name</b>/</small></div>
|
|
162
|
+
<div class="field"><label class="label" for="f_title">${tx('rm.title')}</label><input id="f_title" name="title" required value="${v('title', m.title)}"></div>
|
|
163
|
+
<div class="field"><label class="label" for="f_roles">${tx('rm.privilege_roles')}</label><input id="f_roles" name="roles" value="${b ? String(b.roles ?? '') : m.roles.join(', ')}" placeholder="${tx('rm.any_caller')}"><small class="help">${tx('rm.roles_help')}</small></div>
|
|
164
|
+
<div class="field"><label class="label" for="f_enabled">${tx('rm.status')}</label><select id="f_enabled" name="enabled"><option value="1"${m.enabled ? raw(' selected') : ''}>${tx('rm.enabled')}</option><option value="0"${m.enabled ? '' : raw(' selected')}>${tx('rm.disabled_404')}</option></select></div>
|
|
166
165
|
</div>
|
|
167
|
-
<div class="field"><label class="label" for="f_description"
|
|
168
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
166
|
+
<div class="field"><label class="label" for="f_description">${tx('rm.description')}</label><input id="f_description" name="description" value="${v('description', m.description)}"></div>
|
|
167
|
+
<div class="buttons"><button class="btn btn-hot">${tx('rm.save')}</button></div></form>`;
|
|
169
168
|
const templates = templatesOf(m);
|
|
170
169
|
const tplTable = templates.length
|
|
171
|
-
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th
|
|
172
|
-
${templates.map((t) => html`<tr><td data-label="
|
|
173
|
-
<td data-label="
|
|
174
|
-
<td data-label="
|
|
175
|
-
: html`<p class="muted"
|
|
170
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('rm.template')}</th><th>${tx('rm.handlers')}</th><th>${tx('rm.description')}</th></tr></thead><tbody>
|
|
171
|
+
${templates.map((t) => html`<tr><td data-label="${tx('rm.template')}"><a href="${tplUrl(m, t.uri)}"><code>${t.uri || '/'}</code></a></td>
|
|
172
|
+
<td data-label="${tx('rm.handlers')}">${m.handlers.filter((h) => h.path === t.uri).map((h) => html`<a href="${handlerUrl(m, h)}"><code>${h.method}</code></a> `)}</td>
|
|
173
|
+
<td data-label="${tx('rm.description')}">${t.description ?? ''}</td></tr>`)}</tbody></table></div>`
|
|
174
|
+
: html`<p class="muted">${tx('rm.no_templates')}</p>`;
|
|
176
175
|
const addTpl = html`<form method="post" action="${MODULES}/${m.id}/template" class="u-mt1">${csrf(s)}
|
|
177
176
|
<div class="form-grid">
|
|
178
|
-
<div class="field"><label class="label" for="f_uri"
|
|
179
|
-
<div class="field"><label class="label" for="f_tdesc"
|
|
180
|
-
</div><div class="buttons"><button class="btn">${icon('plus')}
|
|
177
|
+
<div class="field"><label class="label" for="f_uri">${tx('rm.uri_template')}</label><input id="f_uri" name="uri" required placeholder="${tx('rm.eg', { example: 'orders/:id' })}"><small class="help">${tx.code('rm.uri_help')}</small></div>
|
|
178
|
+
<div class="field"><label class="label" for="f_tdesc">${tx('rm.description')}</label><input id="f_tdesc" name="description"></div>
|
|
179
|
+
</div><div class="buttons"><button class="btn">${icon('plus')} ${tx('rm.add_template')}</button></div></form>`;
|
|
181
180
|
const body = html`
|
|
182
|
-
${region('
|
|
183
|
-
${region('
|
|
184
|
-
${region('OpenAPI', html`<p class="wrap-anywhere u-mt0"
|
|
185
|
-
<p class="muted small"
|
|
186
|
-
${region('
|
|
187
|
-
<form method="post" action="${MODULES}/${m.id}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="
|
|
188
|
-
return frame(s, m,
|
|
181
|
+
${region(tx('rm.checks'), await roleChecks(m))}
|
|
182
|
+
${region(tx('rm.templates'), html`${tplTable}${addTpl}`)}
|
|
183
|
+
${region('OpenAPI', html`<p class="wrap-anywhere u-mt0">${tx('rm.openapi_doc')} <a href="${openapi}" target="_blank" rel="noopener"><code>${openapi}</code></a></p>
|
|
184
|
+
<p class="muted small">${tx('rm.openapi_help')}</p>`)}
|
|
185
|
+
${region(tx('rm.module'), html`${settings}
|
|
186
|
+
<form method="post" action="${MODULES}/${m.id}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="${tx('rm.delete_module_confirm', { name: m.name })}">${icon('trash')} ${tx('rm.delete_module')}</button></form>`)}`;
|
|
187
|
+
return frame(s, m, tx('rm.module_title', { name: m.name }), [[m.name]], {}, body);
|
|
189
188
|
}
|
|
190
189
|
|
|
191
|
-
|
|
190
|
+
const templatePage = (s: Session, m: ModRow, uri: string, problems: string[] = []) => withTranslator(tr(s), () => templatePageInner(s, m, uri, problems));
|
|
191
|
+
async function templatePageInner(s: Session, m: ModRow, uri: string, problems: string[]) {
|
|
192
192
|
const t = templatesOf(m).find((x) => x.uri === uri)!;
|
|
193
193
|
const handlers = m.handlers.filter((h) => h.path === uri);
|
|
194
194
|
const used = new Set<string>(handlers.map((h) => h.method));
|
|
195
195
|
const free = METHODS.filter((x) => !used.has(x));
|
|
196
196
|
const body = html`${errorList(problems)}
|
|
197
|
-
${region('
|
|
198
|
-
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th
|
|
199
|
-
${handlers.map((h) => html`<tr><td data-label="
|
|
200
|
-
<td data-label="
|
|
201
|
-
: html`<p class="muted"
|
|
197
|
+
${region(tx('rm.handlers'), html`${handlers.length
|
|
198
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('rm.method')}</th><th>${tx('rm.source_type')}</th><th>${tx('rm.access')}</th><th>${tx('rm.description')}</th></tr></thead><tbody>
|
|
199
|
+
${handlers.map((h) => html`<tr><td data-label="${tx('rm.method')}"><a href="${handlerUrl(m, h)}"><code>${h.method}</code></a></td><td data-label="${tx('rm.source_type')}">${typeLabel(h.type)}</td>
|
|
200
|
+
<td data-label="${tx('rm.access')}">${access(m, h)}</td><td data-label="${tx('rm.description')}">${h.description ?? ''}</td></tr>`)}</tbody></table></div>`
|
|
201
|
+
: html`<p class="muted">${tx('rm.no_handlers')}</p>`}
|
|
202
202
|
${free.length ? html`<form method="get" action="${MODULES}/${m.id}/handler" class="search u-mwnone u-mt1"><input type="hidden" name="t" value="${uri}">
|
|
203
|
-
<div class="field"><label class="label" for="f_new"
|
|
204
|
-
<button class="btn u-selfend">${icon('plus')}
|
|
205
|
-
<p class="wrap-anywhere muted small"
|
|
206
|
-
${region('
|
|
203
|
+
<div class="field"><label class="label" for="f_new">${tx('rm.method')}</label><select id="f_new" name="new">${free.map((x) => html`<option>${x}</option>`)}</select></div>
|
|
204
|
+
<button class="btn u-selfend">${icon('plus')} ${tx('rm.add_handler')}</button></form>` : ''}
|
|
205
|
+
<p class="wrap-anywhere muted small">${tx('rm.url')} <code>${baseUrl(m)}${uri}</code></p>`)}
|
|
206
|
+
${region(tx('rm.template'), html`<form method="post" action="${MODULES}/${m.id}/template">${csrf(s)}<input type="hidden" name="orig" value="${uri}">
|
|
207
207
|
<div class="form-grid">
|
|
208
|
-
<div class="field"><label class="label" for="f_uri"
|
|
209
|
-
<div class="field"><label class="label" for="f_tdesc"
|
|
210
|
-
</div><div class="buttons"><button class="btn btn-hot"
|
|
208
|
+
<div class="field"><label class="label" for="f_uri">${tx('rm.uri_template')}</label><input id="f_uri" name="uri" required value="${uri}"><small class="help">${tx('rm.uri_rename_help')}</small></div>
|
|
209
|
+
<div class="field"><label class="label" for="f_tdesc">${tx('rm.description')}</label><input id="f_tdesc" name="description" value="${t?.description ?? ''}"></div>
|
|
210
|
+
</div><div class="buttons"><button class="btn btn-hot">${tx('rm.save')}</button></div></form>
|
|
211
211
|
<form method="post" action="${MODULES}/${m.id}/template/delete" class="danger-zone">${csrf(s)}<input type="hidden" name="uri" value="${uri}">
|
|
212
|
-
<button class="btn btn-danger" data-confirm="
|
|
212
|
+
<button class="btn btn-danger" data-confirm="${tx('rm.delete_template_confirm', { uri, n: handlers.length })}">${icon('trash')} ${tx('rm.delete_template')}</button></form>`)}`;
|
|
213
213
|
return frame(s, m, `REST ${m.name}/${uri}`, [[m.name, `${MODULES}/${m.id}`], [uri || '/']], { t: uri }, body);
|
|
214
214
|
}
|
|
215
215
|
|
|
216
216
|
/** "name in [type] [required] [description]" per line ↔ the handler's params. */
|
|
217
|
-
export function parseParams(text: string): { params: HandlerParam[]; problems: string[] } {
|
|
217
|
+
export function parseParams(text: string, t: BuilderTranslate = translatorFor('en')): { params: HandlerParam[]; problems: string[] } {
|
|
218
218
|
const params: HandlerParam[] = [];
|
|
219
219
|
const problems: string[] = [];
|
|
220
220
|
for (const [i, line] of text.split(/\r?\n/).entries()) {
|
|
@@ -222,7 +222,7 @@ export function parseParams(text: string): { params: HandlerParam[]; problems: s
|
|
|
222
222
|
if (!words.length) continue;
|
|
223
223
|
const [name, where, ...rest] = words;
|
|
224
224
|
if (where !== 'query' && where !== 'body') {
|
|
225
|
-
problems.push(
|
|
225
|
+
problems.push(t('rm.e.params_line', { n: i + 1, types: PARAM_TYPES.join(', ') }));
|
|
226
226
|
continue;
|
|
227
227
|
}
|
|
228
228
|
const p: HandlerParam = { name, in: where };
|
|
@@ -252,7 +252,9 @@ interface TryResult {
|
|
|
252
252
|
error?: string;
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
-
|
|
255
|
+
type HandlerOpts = { problems?: string[]; form?: Record<string, unknown>; tried?: TryResult; tryForm?: Record<string, unknown> };
|
|
256
|
+
const handlerPage = (s: Session, m: ModRow, uri: string, method: string | null, opts: HandlerOpts = {}) => withTranslator(tr(s), () => handlerPageInner(s, m, uri, method, opts));
|
|
257
|
+
async function handlerPageInner(s: Session, m: ModRow, uri: string, method: string | null, opts: HandlerOpts) {
|
|
256
258
|
const existing = method ? m.handlers.find((h) => h.path === uri && h.method === method) : undefined;
|
|
257
259
|
const isNew = !existing;
|
|
258
260
|
const f = opts.form;
|
|
@@ -262,30 +264,30 @@ async function handlerPage(s: Session, m: ModRow, uri: string, method: string |
|
|
|
262
264
|
const editor = html`<form method="post" action="${MODULES}/${m.id}/handler">${csrf(s)}${errorList(opts.problems ?? [])}
|
|
263
265
|
<input type="hidden" name="t" value="${uri}">${existing ? html`<input type="hidden" name="orig_m" value="${existing.method}">` : ''}
|
|
264
266
|
<div class="form-grid">
|
|
265
|
-
<div class="field"><label class="label" for="f_method"
|
|
266
|
-
<div class="field"><label class="label" for="f_type"
|
|
267
|
-
<div class="field"><label class="label" for="f_description"
|
|
267
|
+
<div class="field"><label class="label" for="f_method">${tx('rm.method')}</label><select id="f_method" name="method">${METHODS.map((x) => html`<option${v('method', h.method) === x ? raw(' selected') : ''}>${x}</option>`)}</select></div>
|
|
268
|
+
<div class="field"><label class="label" for="f_type">${tx('rm.source_type')}</label><select id="f_type" name="type">${TYPES.map((k) => html`<option value="${k}"${type === k ? raw(' selected') : ''}>${typeLabel(k)}</option>`)}</select></div>
|
|
269
|
+
<div class="field"><label class="label" for="f_description">${tx('rm.description')}</label><input id="f_description" name="description" value="${v('description', h.description)}"></div>
|
|
268
270
|
</div>
|
|
269
|
-
<div class="field"><label class="label" for="f_source"
|
|
271
|
+
<div class="field"><label class="label" for="f_source">${tx('rm.source')}</label>
|
|
270
272
|
<textarea id="f_source" name="source" class="code sql-editor" rows="10" spellcheck="false" data-code="${type === 'plpgsql' ? 'plpgsql' : 'sql'}" required>${v('source', h.source)}</textarea>
|
|
271
|
-
<small class="help"
|
|
272
|
-
<fieldset class="prop-group"><legend
|
|
273
|
-
<label class="check"><input type="radio" name="auth" value="token"${v('auth', h.auth ?? 'token') !== 'public' ? raw(' checked') : ''}>
|
|
274
|
-
<label class="check"><input type="radio" name="auth" value="public"${v('auth', h.auth) === 'public' ? raw(' checked') : ''}>
|
|
273
|
+
<small class="help">${tx.code('rm.source_help', { params: pathParams(uri).map((p) => `:${p.toUpperCase()}`).join(', ') || tx('rm.none_here') })}</small></div>
|
|
274
|
+
<fieldset class="prop-group"><legend>${tx('rm.who')}</legend>
|
|
275
|
+
<label class="check"><input type="radio" name="auth" value="token"${v('auth', h.auth ?? 'token') !== 'public' ? raw(' checked') : ''}> ${m.roles.length ? tx('rm.auth_token_role', { roles: anyOf(m.roles) }) : tx('rm.auth_token')}</label>
|
|
276
|
+
<label class="check"><input type="radio" name="auth" value="public"${v('auth', h.auth) === 'public' ? raw(' checked') : ''}> ${tx('rm.auth_public')} <small class="help">${m.roles.length ? tx.code('rm.auth_public_403') : tx.code('rm.auth_public_help')}</small></label>
|
|
275
277
|
</fieldset>
|
|
276
278
|
<div class="form-grid">
|
|
277
|
-
<div class="field"><label class="label" for="f_roles"
|
|
278
|
-
<div class="field"><label class="label" for="f_page_size"
|
|
279
|
-
<div class="field"><label class="label" for="f_status"
|
|
279
|
+
<div class="field"><label class="label" for="f_roles">${tx('rm.roles')}</label><input id="f_roles" name="roles" value="${f ? String(f.roles ?? '') : (h.roles ?? []).join(', ')}" placeholder="${tx('rm.no_further_role')}"><small class="help">${tx('rm.roles_help_handler')}</small></div>
|
|
280
|
+
<div class="field"><label class="label" for="f_page_size">${tx('rm.page_size')}</label><input id="f_page_size" name="page_size" type="number" min="1" max="500" value="${v('page_size', h.page_size)}" placeholder="25"><small class="help">${tx('rm.page_size_help')}</small></div>
|
|
281
|
+
<div class="field"><label class="label" for="f_status">${tx('rm.status')}</label><input id="f_status" name="status" type="number" min="200" max="299" value="${v('status', h.status)}" placeholder="${tx('rm.status_ph')}"><small class="help">${tx('rm.status_help')}</small></div>
|
|
280
282
|
</div>
|
|
281
|
-
<div class="field"><label class="label" for="f_params"
|
|
283
|
+
<div class="field"><label class="label" for="f_params">${tx('rm.params')}</label>
|
|
282
284
|
<textarea id="f_params" name="params" class="code" rows="3" spellcheck="false" placeholder="start_date body string required First day">${f ? String(f.params ?? '') : paramsText(h.params)}</textarea>
|
|
283
|
-
<small class="help"
|
|
284
|
-
<div class="buttons"><button class="btn btn-hot">${isNew ? '
|
|
285
|
+
<small class="help">${tx.code('rm.params_help', { types: PARAM_TYPES.join('|') })}</small></div>
|
|
286
|
+
<div class="buttons"><button class="btn btn-hot">${isNew ? tx('rm.create_handler') : tx('rm.save')}</button></div></form>
|
|
285
287
|
${existing ? html`<form method="post" action="${MODULES}/${m.id}/handler/delete" class="danger-zone">${csrf(s)}<input type="hidden" name="t" value="${uri}"><input type="hidden" name="m" value="${existing.method}">
|
|
286
|
-
<button class="btn btn-danger" data-confirm="
|
|
287
|
-
const body = html`${region(isNew ?
|
|
288
|
-
return frame(s, m, `REST ${h.method} ${m.name}/${uri}`, [[m.name, `${MODULES}/${m.id}`], [uri || '/', tplUrl(m, uri)], [isNew ? '
|
|
288
|
+
<button class="btn btn-danger" data-confirm="${tx('rm.delete_handler_confirm', { method: existing.method, uri })}">${icon('trash')} ${tx('rm.delete_handler')}</button></form>` : ''}`;
|
|
289
|
+
const body = html`${region(isNew ? tx('rm.new_handler_for', { uri: uri || '/' }) : `${h.method} ${uri || '/'}`, editor)}${existing ? await tryPanel(s, m, existing, opts.tried, opts.tryForm) : ''}`;
|
|
290
|
+
return frame(s, m, `REST ${h.method} ${m.name}/${uri}`, [[m.name, `${MODULES}/${m.id}`], [uri || '/', tplUrl(m, uri)], [isNew ? tx('rm.new_handler') : String(h.method)]], { t: uri, m: existing?.method }, body);
|
|
289
291
|
}
|
|
290
292
|
|
|
291
293
|
/** Accounts a Try it request may run as: those with access to the application (all active ones for "any user"). */
|
|
@@ -302,21 +304,21 @@ async function tryPanel(s: Session, m: ModRow, h: Handler, tried?: TryResult, f?
|
|
|
302
304
|
const qs = (h.params ?? []).filter((p) => p.in === 'query').map((p) => `${p.name}=`).join('&');
|
|
303
305
|
const bodyEx = h.method === 'GET' || h.method === 'DELETE' ? '' : JSON.stringify(Object.fromEntries((h.params ?? []).filter((p) => p.in === 'body').map((p) => [p.name, p.type === 'integer' || p.type === 'number' ? 0 : p.type === 'boolean' ? false : ''])), null, 2);
|
|
304
306
|
const result = tried
|
|
305
|
-
? html`<div class="u-mt1" role="status"><h3 class="small"
|
|
306
|
-
<p class="wrap-anywhere"
|
|
307
|
+
? html`<div class="u-mt1" role="status"><h3 class="small">${tx('rm.response')}</h3>
|
|
308
|
+
<p class="wrap-anywhere">${tx.rich('rm.response_as', { request: raw(`<code>${esc(`${tried.method} ${tried.url}`)}</code>`), as: raw(`<b>${esc(tried.as || tx('rm.no_token'))}</b>`) })}</p>
|
|
307
309
|
${tried.error ? html`<div class="alert alert-error">${tried.error}</div>` : html`<p><b>${tried.status}</b> <span class="muted">${tried.type ?? ''}</span></p>
|
|
308
310
|
${tried.body ? html`<pre class="source wrap-pre">${tried.body}</pre>` : ''}`}</div>`
|
|
309
311
|
: '';
|
|
310
312
|
const params = pathParams(h.path);
|
|
311
|
-
return region('
|
|
312
|
-
<p class="muted u-mt0"
|
|
313
|
+
return region(tx('rm.try_it'), html`<form method="post" action="${MODULES}/${m.id}/try">${csrf(s)}<input type="hidden" name="t" value="${h.path}"><input type="hidden" name="m" value="${h.method}">
|
|
314
|
+
<p class="muted u-mt0">${tx.rich('rm.try_intro', { as: raw(`<em>${esc(tx('rm.as'))}</em>`) })}</p>
|
|
313
315
|
<div class="form-grid">
|
|
314
|
-
<div class="field"><label class="label" for="f_as"
|
|
316
|
+
<div class="field"><label class="label" for="f_as">${tx('rm.as')}</label><select id="f_as" name="as"><option value="">${tx('rm.no_token_option')}</option>${accounts.map((u) => html`<option${v('as') === u ? raw(' selected') : ''}>${u}</option>`)}</select></div>
|
|
315
317
|
${params.map((p) => html`<div class="field"><label class="label" for="f_p_${p}">:${p}</label><input id="f_p_${p}" name="p_${p}" value="${v(`p_${p}`)}" required></div>`)}
|
|
316
|
-
<div class="field"><label class="label" for="f_query"
|
|
318
|
+
<div class="field"><label class="label" for="f_query">${tx('rm.query_string')}</label><input id="f_query" name="query" value="${v('query', qs)}" placeholder="limit=10&offset=0"></div>
|
|
317
319
|
</div>
|
|
318
|
-
${h.method === 'GET' || h.method === 'DELETE' ? '' : html`<div class="field"><label class="label" for="f_body"
|
|
319
|
-
<div class="buttons"><button class="btn btn-hot">${icon('play')}
|
|
320
|
+
${h.method === 'GET' || h.method === 'DELETE' ? '' : html`<div class="field"><label class="label" for="f_body">${tx('rm.json_body')}</label><textarea id="f_body" name="body" class="code" rows="5" spellcheck="false" data-code="json">${v('body', bodyEx)}</textarea></div>`}
|
|
321
|
+
<div class="buttons"><button class="btn btn-hot">${icon('play')} ${tx('rm.send', { method: h.method })}</button></div></form>
|
|
320
322
|
${result}
|
|
321
323
|
<h3 class="small">cURL</h3><pre class="source wrap-pre">curl${h.method === 'GET' ? '' : ` -X ${h.method}`} "${baseUrl(m)}${h.path.replace(/:([a-z][a-z0-9_]*)/gi, (_, p) => `<${p}>`)}"${h.auth === 'public' ? '' : ' -H "Authorization: Bearer $TOKEN"'}${bodyEx ? ` -H "Content-Type: application/json" -d '${bodyEx.replace(/\s+/g, ' ')}'` : ''}</pre>`);
|
|
322
324
|
}
|
|
@@ -324,10 +326,10 @@ async function tryPanel(s: Session, m: ModRow, h: Handler, tried?: TryResult, f?
|
|
|
324
326
|
// ------------------------------------------------------------------ changes
|
|
325
327
|
|
|
326
328
|
/** Change a module's definition in a transaction (row locked), after the Run only / lock check. */
|
|
327
|
-
async function change(m: ModRow, fn: (cur: { handlers: Handler[]; templates: Template[] }) => { handlers: Handler[]; templates: Template[] } | string[]): Promise<string[]> {
|
|
329
|
+
async function change(t: BuilderTranslate, m: ModRow, fn: (cur: { handlers: Handler[]; templates: Template[] }) => { handlers: Handler[]; templates: Template[] } | string[]): Promise<string[]> {
|
|
328
330
|
return owner.tx(async (c) => {
|
|
329
331
|
const cur = (await c.query('select handlers, templates from meta.rest_module where id = $1 for update', [m.id])).rows[0];
|
|
330
|
-
if (!cur) return ['
|
|
332
|
+
if (!cur) return [t('rm.e.module_gone')];
|
|
331
333
|
const next = fn({ handlers: Array.isArray(cur.handlers) ? cur.handlers : [], templates: Array.isArray(cur.templates) ? cur.templates : [] });
|
|
332
334
|
if (Array.isArray(next)) return next;
|
|
333
335
|
const problems = [...templateProblems(next.templates), ...handlerProblems(next.handlers)];
|
|
@@ -338,28 +340,28 @@ async function change(m: ModRow, fn: (cur: { handlers: Handler[]; templates: Tem
|
|
|
338
340
|
}
|
|
339
341
|
|
|
340
342
|
/** A handler from the form, or the problems with it. */
|
|
341
|
-
function readHandler(b: Record<string, unknown>, uri: string): { h: Handler; problems: string[] } {
|
|
343
|
+
function readHandler(t: BuilderTranslate, b: Record<string, unknown>, uri: string): { h: Handler; problems: string[] } {
|
|
342
344
|
const problems: string[] = [];
|
|
343
345
|
const str = (k: string) => String(b[k] ?? '').trim();
|
|
344
346
|
const h: Handler = { method: str('method') as Handler['method'], path: uri, type: str('type') as Handler['type'], source: String(b.source ?? '').replace(/\r\n/g, '\n').trim() };
|
|
345
347
|
const auth = str('auth') || 'token';
|
|
346
348
|
if (auth === 'public') h.auth = 'public';
|
|
347
|
-
else if (auth !== 'token') problems.push('
|
|
349
|
+
else if (auth !== 'token') problems.push(t('rm.e.auth'));
|
|
348
350
|
if (str('description')) h.description = str('description').slice(0, 500);
|
|
349
351
|
const roles = splitRoles(str('roles'));
|
|
350
|
-
if (roles.some((r) => r.length > 100)) problems.push('
|
|
352
|
+
if (roles.some((r) => r.length > 100)) problems.push(t('rm.e.role_length'));
|
|
351
353
|
if (roles.length) h.roles = roles;
|
|
352
354
|
if (str('page_size')) {
|
|
353
355
|
const n = Number(str('page_size'));
|
|
354
|
-
if (!Number.isInteger(n) || n < 1 || n > 500) problems.push('
|
|
356
|
+
if (!Number.isInteger(n) || n < 1 || n > 500) problems.push(t('rm.e.page_size'));
|
|
355
357
|
else h.page_size = n;
|
|
356
358
|
}
|
|
357
359
|
if (str('status')) {
|
|
358
360
|
const n = Number(str('status'));
|
|
359
|
-
if (!Number.isInteger(n) || n < 200 || n > 299) problems.push('
|
|
361
|
+
if (!Number.isInteger(n) || n < 200 || n > 299) problems.push(t('rm.e.status'));
|
|
360
362
|
else h.status = n;
|
|
361
363
|
}
|
|
362
|
-
const p = parseParams(String(b.params ?? ''));
|
|
364
|
+
const p = parseParams(String(b.params ?? ''), t);
|
|
363
365
|
problems.push(...p.problems);
|
|
364
366
|
if (p.params.length) h.params = p.params;
|
|
365
367
|
return { h, problems };
|
|
@@ -392,12 +394,12 @@ export async function restModuleRoutes(app: FastifyInstance) {
|
|
|
392
394
|
const b = (req.body ?? {}) as Record<string, string>;
|
|
393
395
|
const again = async (problems: string[]) => send(reply.code(422), s, await listPage(s, b, problems));
|
|
394
396
|
const a = (await workspaceApps(s)).find((x) => String(x.id) === String(b.app_id ?? ''));
|
|
395
|
-
if (!a) return again(['
|
|
397
|
+
if (!a) return again([tr(s)('rm.e.choose_app')]);
|
|
396
398
|
const name = String(b.name ?? '').trim();
|
|
397
399
|
const title = String(b.title ?? '').trim();
|
|
398
400
|
const problems: string[] = [];
|
|
399
|
-
if (!NAME.test(name)) problems.push(
|
|
400
|
-
if (!title || title.length > 200) problems.push('
|
|
401
|
+
if (!NAME.test(name)) problems.push(tr(s)('rm.e.base_path'));
|
|
402
|
+
if (!title || title.length > 200) problems.push(tr(s)('rm.e.title'));
|
|
401
403
|
const roles = splitRoles(b.roles);
|
|
402
404
|
const refused = await changeRefused(s, a.id);
|
|
403
405
|
if (refused) problems.push(refused);
|
|
@@ -407,11 +409,11 @@ export async function restModuleRoutes(app: FastifyInstance) {
|
|
|
407
409
|
`insert into meta.rest_module (app_id, name, title, description, roles, handlers, templates) values ($1, $2, $3, $4, $5, '[]', '[]') returning id`,
|
|
408
410
|
[a.id, name, title, String(b.description ?? '').trim() || null, roles]);
|
|
409
411
|
await log(req, s, { app_id: a.id, name }, 'module created');
|
|
410
|
-
flash(s,
|
|
412
|
+
flash(s, tr(s)('rm.created', { name, path: `/a/${a.alias}/rest/${name}/` }));
|
|
411
413
|
return back(reply, s, `${MODULES}/${r!.id}`);
|
|
412
414
|
} catch (e) {
|
|
413
415
|
if ((e as { code?: string }).code !== '23505') throw e;
|
|
414
|
-
return again([
|
|
416
|
+
return again([tr(s)('rm.e.exists', { app: a.name, name })]);
|
|
415
417
|
}
|
|
416
418
|
});
|
|
417
419
|
|
|
@@ -432,8 +434,8 @@ export async function restModuleRoutes(app: FastifyInstance) {
|
|
|
432
434
|
const name = String(b.name ?? '').trim();
|
|
433
435
|
const title = String(b.title ?? '').trim();
|
|
434
436
|
const problems: string[] = [];
|
|
435
|
-
if (!NAME.test(name)) problems.push(
|
|
436
|
-
if (!title || title.length > 200) problems.push('
|
|
437
|
+
if (!NAME.test(name)) problems.push(tr(s)('rm.e.base_path'));
|
|
438
|
+
if (!title || title.length > 200) problems.push(tr(s)('rm.e.title'));
|
|
437
439
|
const refused = await changeRefused(s, m.app_id);
|
|
438
440
|
if (refused) problems.push(refused);
|
|
439
441
|
if (!problems.length) {
|
|
@@ -442,12 +444,12 @@ export async function restModuleRoutes(app: FastifyInstance) {
|
|
|
442
444
|
[m.id, name, title, String(b.description ?? '').trim() || null, splitRoles(b.roles), b.enabled !== '0']);
|
|
443
445
|
} catch (e) {
|
|
444
446
|
if ((e as { code?: string }).code !== '23505') throw e;
|
|
445
|
-
problems.push(
|
|
447
|
+
problems.push(tr(s)('rm.e.exists', { app: m.app_name, name }));
|
|
446
448
|
}
|
|
447
449
|
}
|
|
448
450
|
if (problems.length) return send(reply.code(422), s, await modulePage(s, m, problems, b));
|
|
449
451
|
await log(req, s, { app_id: m.app_id, name }, `module saved (${b.enabled === '0' ? 'disabled' : 'enabled'}, roles: ${splitRoles(b.roles).join(', ') || 'none'})`);
|
|
450
|
-
flash(s, '
|
|
452
|
+
flash(s, tr(s)('rm.saved'));
|
|
451
453
|
return back(reply, s, `${MODULES}/${m.id}`);
|
|
452
454
|
});
|
|
453
455
|
|
|
@@ -463,7 +465,7 @@ export async function restModuleRoutes(app: FastifyInstance) {
|
|
|
463
465
|
}
|
|
464
466
|
await owner.query('delete from meta.rest_module where id = $1', [m.id]);
|
|
465
467
|
await log(req, s, m, 'module deleted');
|
|
466
|
-
flash(s,
|
|
468
|
+
flash(s, tr(s)('rm.deleted', { name: m.name }));
|
|
467
469
|
return back(reply, s, MODULES);
|
|
468
470
|
});
|
|
469
471
|
|
|
@@ -488,10 +490,10 @@ export async function restModuleRoutes(app: FastifyInstance) {
|
|
|
488
490
|
const orig = b.orig === undefined ? null : String(b.orig);
|
|
489
491
|
const description = String(b.description ?? '').trim().slice(0, 500);
|
|
490
492
|
const refused = await changeRefused(s, m.app_id);
|
|
491
|
-
const problems = refused ? [refused] : await change(m, (cur) => {
|
|
493
|
+
const problems = refused ? [refused] : await change(tr(s), m, (cur) => {
|
|
492
494
|
const list = templatesOf(cur);
|
|
493
|
-
if (orig === null && list.some((t) => t.uri === uri)) return [
|
|
494
|
-
if (orig !== null && !list.some((t) => t.uri === orig)) return ['
|
|
495
|
+
if (orig === null && list.some((t) => t.uri === uri)) return [tr(s)('rm.e.template_exists', { uri })];
|
|
496
|
+
if (orig !== null && !list.some((t) => t.uri === orig)) return [tr(s)('rm.e.template_gone')];
|
|
495
497
|
const next = list.filter((t) => t.uri !== orig).map((t) => ({ uri: t.uri, ...(t.description ? { description: t.description } : {}) }));
|
|
496
498
|
next.push({ uri, ...(description ? { description } : {}) });
|
|
497
499
|
return { templates: next.sort((x, y) => x.uri.localeCompare(y.uri)), handlers: cur.handlers.map((h) => (orig !== null && h.path === orig ? { ...h, path: uri } : h)) };
|
|
@@ -501,7 +503,7 @@ export async function restModuleRoutes(app: FastifyInstance) {
|
|
|
501
503
|
return send(reply.code(422), s, await modulePage(s, m, problems));
|
|
502
504
|
}
|
|
503
505
|
await log(req, s, m, orig === null ? `template ${uri} added` : `template ${orig} saved as ${uri}`);
|
|
504
|
-
flash(s, '
|
|
506
|
+
flash(s, tr(s)('rm.saved'));
|
|
505
507
|
return back(reply, s, tplUrl(m, uri));
|
|
506
508
|
});
|
|
507
509
|
|
|
@@ -512,13 +514,13 @@ export async function restModuleRoutes(app: FastifyInstance) {
|
|
|
512
514
|
if (!m) return;
|
|
513
515
|
const uri = String(req.body?.uri ?? '');
|
|
514
516
|
const refused = await changeRefused(s, m.app_id);
|
|
515
|
-
const problems = refused ? [refused] : await change(m, (cur) => ({ templates: cur.templates.filter((t) => t?.uri !== uri), handlers: cur.handlers.filter((h) => h.path !== uri) }));
|
|
517
|
+
const problems = refused ? [refused] : await change(tr(s), m, (cur) => ({ templates: cur.templates.filter((t) => t?.uri !== uri), handlers: cur.handlers.filter((h) => h.path !== uri) }));
|
|
516
518
|
if (problems.length) {
|
|
517
519
|
flash(s, problems.join(' '), 'error');
|
|
518
520
|
return back(reply, s, `${MODULES}/${m.id}`);
|
|
519
521
|
}
|
|
520
522
|
await log(req, s, m, `template ${uri} deleted`);
|
|
521
|
-
flash(s,
|
|
523
|
+
flash(s, tr(s)('rm.template_deleted', { uri: uri || '/' }));
|
|
522
524
|
return back(reply, s, `${MODULES}/${m.id}`);
|
|
523
525
|
});
|
|
524
526
|
|
|
@@ -544,12 +546,12 @@ export async function restModuleRoutes(app: FastifyInstance) {
|
|
|
544
546
|
const uri = String(b.t ?? '');
|
|
545
547
|
const orig = b.orig_m ? String(b.orig_m) : null;
|
|
546
548
|
if (!templatesOf(m).some((t) => t.uri === uri)) return reply.code(404).send('Not found');
|
|
547
|
-
const { h, problems } = readHandler(b, uri);
|
|
549
|
+
const { h, problems } = readHandler(tr(s), b, uri);
|
|
548
550
|
const refused = await changeRefused(s, m.app_id);
|
|
549
551
|
if (refused) problems.push(refused);
|
|
550
552
|
if (!problems.length)
|
|
551
|
-
problems.push(...await change(m, (cur) => {
|
|
552
|
-
if (orig !== null && !cur.handlers.some((x) => x.path === uri && x.method === orig)) return ['
|
|
553
|
+
problems.push(...await change(tr(s), m, (cur) => {
|
|
554
|
+
if (orig !== null && !cur.handlers.some((x) => x.path === uri && x.method === orig)) return [tr(s)('rm.e.handler_gone')];
|
|
553
555
|
const at = orig === null ? -1 : cur.handlers.findIndex((x) => x.path === uri && x.method === orig);
|
|
554
556
|
const handlers = [...cur.handlers];
|
|
555
557
|
if (at >= 0) handlers[at] = h;
|
|
@@ -558,7 +560,7 @@ export async function restModuleRoutes(app: FastifyInstance) {
|
|
|
558
560
|
}));
|
|
559
561
|
if (problems.length) return send(reply.code(422), s, await handlerPage(s, m, uri, orig, { problems, form: b }));
|
|
560
562
|
await log(req, s, m, `handler ${h.method} ${uri} ${orig === null ? 'created' : 'saved'} (${h.type}, ${h.auth === 'public' ? 'public' : 'token'})`);
|
|
561
|
-
flash(s, '
|
|
563
|
+
flash(s, tr(s)('rm.saved'));
|
|
562
564
|
return back(reply, s, handlerUrl(m, h));
|
|
563
565
|
});
|
|
564
566
|
|
|
@@ -570,13 +572,13 @@ export async function restModuleRoutes(app: FastifyInstance) {
|
|
|
570
572
|
const uri = String(req.body?.t ?? '');
|
|
571
573
|
const method = String(req.body?.m ?? '');
|
|
572
574
|
const refused = await changeRefused(s, m.app_id);
|
|
573
|
-
const problems = refused ? [refused] : await change(m, (cur) => ({ templates: cur.templates, handlers: cur.handlers.filter((h) => !(h.path === uri && h.method === method)) }));
|
|
575
|
+
const problems = refused ? [refused] : await change(tr(s), m, (cur) => ({ templates: cur.templates, handlers: cur.handlers.filter((h) => !(h.path === uri && h.method === method)) }));
|
|
574
576
|
if (problems.length) {
|
|
575
577
|
flash(s, problems.join(' '), 'error');
|
|
576
578
|
return back(reply, s, `${MODULES}/${m.id}`);
|
|
577
579
|
}
|
|
578
580
|
await log(req, s, m, `handler ${method} ${uri} deleted`);
|
|
579
|
-
flash(s,
|
|
581
|
+
flash(s, tr(s)('rm.handler_deleted', { method, uri: uri || '/' }));
|
|
580
582
|
return back(reply, s, templatesOf({ ...m, handlers: m.handlers.filter((h) => !(h.path === uri && h.method === method)) }).some((t) => t.uri === uri) ? tplUrl(m, uri) : `${MODULES}/${m.id}`);
|
|
581
583
|
});
|
|
582
584
|
|
|
@@ -598,7 +600,7 @@ export async function restModuleRoutes(app: FastifyInstance) {
|
|
|
598
600
|
try {
|
|
599
601
|
const headers: Record<string, string> = {};
|
|
600
602
|
if (as) {
|
|
601
|
-
if (!(await tryAccounts(m.app_id)).includes(as)) throw new Error('
|
|
603
|
+
if (!(await tryAccounts(m.app_id)).includes(as)) throw new Error(tr(s)('rm.e.choose_account'));
|
|
602
604
|
headers.authorization = `Bearer ${(await issueApiToken(m.app_id, as, 1, { internal: true })).token}`;
|
|
603
605
|
}
|
|
604
606
|
const raw = h.method === 'GET' || h.method === 'DELETE' ? '' : String(b.body ?? '').trim();
|
|
@@ -606,7 +608,7 @@ export async function restModuleRoutes(app: FastifyInstance) {
|
|
|
606
608
|
try {
|
|
607
609
|
JSON.parse(raw);
|
|
608
610
|
} catch {
|
|
609
|
-
throw new Error('
|
|
611
|
+
throw new Error(tr(s)('rm.e.bad_json'));
|
|
610
612
|
}
|
|
611
613
|
headers['content-type'] = 'application/json';
|
|
612
614
|
}
|
|
@@ -618,8 +620,8 @@ export async function restModuleRoutes(app: FastifyInstance) {
|
|
|
618
620
|
try {
|
|
619
621
|
text = JSON.stringify(JSON.parse(res.body), null, 2);
|
|
620
622
|
} catch { /* as sent */ }
|
|
621
|
-
tried.body = text.length > MAX_TRY_BODY ? `${text.slice(0, MAX_TRY_BODY)}\n…
|
|
622
|
-
} else if (res.rawPayload.length) tried.body =
|
|
623
|
+
tried.body = text.length > MAX_TRY_BODY ? `${text.slice(0, MAX_TRY_BODY)}\n… ${tr(s)('rm.characters', { n: text.length })}` : text;
|
|
624
|
+
} else if (res.rawPayload.length) tried.body = tr(s)('rm.bytes_of', { n: res.rawPayload.length, type: tried.type || tr(s)('rm.content') });
|
|
623
625
|
await log(req, s, m, `tried ${h.method} ${uri} as ${as || 'no token'}: ${res.statusCode}`);
|
|
624
626
|
} catch (e) {
|
|
625
627
|
tried.error = (e as Error).message;
|