pgkiln 0.37.0 → 0.38.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 +1 -0
- package/db/migrations/091_workspace_service.sql +20 -0
- package/db/migrations/092_app_groups.sql +23 -0
- package/docs/apex-feature-parity.md +13 -8
- package/docs/guide/03-builder.md +76 -5
- package/docs/guide/12-development.md +6 -0
- package/examples/gallery/README.md +13 -0
- package/examples/gallery/gallery.json +20 -0
- package/examples/gallery/starters/asset-inventory.json +30 -0
- package/examples/gallery/starters/contacts.json +41 -0
- package/examples/gallery/starters/event-registrations.json +29 -0
- package/examples/gallery/starters/expense-claims.json +32 -0
- package/examples/gallery/starters/team-tasks.json +35 -0
- package/package.json +1 -1
- package/public/builder.css +91 -0
- package/src/app.ts +6 -0
- package/src/builder/app-groups.ts +132 -0
- package/src/builder/blueprints.ts +14 -0
- package/src/builder/cross-app.ts +106 -0
- package/src/builder/gallery.ts +154 -0
- package/src/builder/home.ts +27 -8
- package/src/builder/routes.ts +76 -24
- package/src/builder/scripts.ts +6 -2
- package/src/builder/sql.ts +106 -7
- package/src/builder/ui.ts +30 -7
- package/src/builder/workspaces.ts +116 -7
- package/src/cli/replace.ts +2 -0
- package/src/runtime/calendar.ts +2 -1
- package/src/session.ts +13 -2
package/public/builder.css
CHANGED
|
@@ -437,6 +437,41 @@ kbd { display: inline-block; padding: 0 0.35em; border: 1px solid var(--border);
|
|
|
437
437
|
.ide-login { background: linear-gradient(180deg, var(--chrome) 0 40%, var(--bg) 40%); color: var(--text); }
|
|
438
438
|
.ide-login .btn-hot { background: var(--hot); border-color: var(--hot); color: var(--hot-text); }
|
|
439
439
|
.ide-login .btn-hot:hover { background: var(--hot-hover); }
|
|
440
|
+
.signin-body.ide-login { display: block; background: var(--bg); }
|
|
441
|
+
.signin { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); width: 100%; max-width: none; min-height: 100vh; min-height: 100dvh; margin: 0; padding: 0; }
|
|
442
|
+
.signin-hero {
|
|
443
|
+
display: flex; flex-direction: column; justify-content: flex-start; gap: 0.75rem; padding: 3rem;
|
|
444
|
+
color: var(--chrome-text); background-color: var(--chrome);
|
|
445
|
+
background-image: repeating-linear-gradient(135deg, rgb(255 255 255 / 0.025) 0 2px, transparent 2px 14px),
|
|
446
|
+
repeating-linear-gradient(45deg, rgb(255 255 255 / 0.02) 0 1px, transparent 1px 22px);
|
|
447
|
+
}
|
|
448
|
+
.signin-logo { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 8px; background: var(--hot); color: var(--hot-text); font-weight: 700; font-size: 18px; margin-bottom: 2rem; }
|
|
449
|
+
.signin-title { margin: 0; font-size: clamp(2rem, 4vw, 2.75rem); font-weight: 750; color: var(--chrome-text); }
|
|
450
|
+
.signin-tagline { margin: 0; font-size: 1.2rem; color: var(--chrome-muted); max-width: 30ch; }
|
|
451
|
+
.signin-foot { margin: auto 0 0; font-size: 0.85rem; color: var(--chrome-muted); }
|
|
452
|
+
.signin-panel { display: grid; place-items: center; padding: 2rem 1rem; background: var(--surface); }
|
|
453
|
+
.signin-form { display: flex; flex-direction: column; gap: 0.85rem; width: 100%; max-width: 420px; }
|
|
454
|
+
.signin-form h2 { margin: 0; font-size: 1.5rem; }
|
|
455
|
+
.signin-form .field { margin: 0; }
|
|
456
|
+
.signin-input { display: flex; align-items: center; gap: 0.6rem; padding: 0 0.75rem; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); }
|
|
457
|
+
.signin-input:focus-within { border-color: var(--accent); outline: 2px solid var(--accent); outline-offset: -1px; }
|
|
458
|
+
.signin-input > .icon { flex: none; width: 18px; height: 18px; color: var(--muted); }
|
|
459
|
+
.ide-login .signin-input input { flex: 1; width: auto; min-width: 0; border: 0; border-radius: 0; box-shadow: none; background: transparent; padding: 0.7rem 0; font-size: 1rem; color: var(--text); outline: none; }
|
|
460
|
+
.signin-reveal { flex: none; border: 0; background: none; color: var(--accent); cursor: pointer; font-size: 0.85rem; padding: 0.25rem; }
|
|
461
|
+
.signin-remember { font-size: 0.9rem; }
|
|
462
|
+
.signin-submit { padding: 0.75rem; font-size: 1rem; }
|
|
463
|
+
.signin-hint { margin: 0; text-align: center; }
|
|
464
|
+
.signin-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.5rem 1.5rem; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
|
|
465
|
+
.signin-actions a { display: inline-flex; align-items: center; gap: 0.4rem; color: var(--gold); text-decoration: none; }
|
|
466
|
+
.signin-actions a:hover { text-decoration: underline; }
|
|
467
|
+
@media (max-width: 760px) {
|
|
468
|
+
.signin { grid-template-columns: 1fr; }
|
|
469
|
+
.signin-hero { padding: 1.25rem 1rem; gap: 0.25rem; }
|
|
470
|
+
.signin-logo { margin-bottom: 0.5rem; width: 36px; height: 36px; font-size: 15px; }
|
|
471
|
+
.signin-tagline { font-size: 1rem; }
|
|
472
|
+
.signin-foot { display: none; }
|
|
473
|
+
.signin-panel { place-items: start center; }
|
|
474
|
+
}
|
|
440
475
|
|
|
441
476
|
/* ------------------------------------------------------------ small screens */
|
|
442
477
|
/* ------------------------------------------------------------ status bar (every builder page, like APEX's footer) */
|
|
@@ -781,3 +816,59 @@ details.prop-group-more[open] > summary { margin-bottom: 8px; }
|
|
|
781
816
|
.pe-help-body { padding-top: 0.4rem; overflow-wrap: anywhere; }
|
|
782
817
|
.pe-help-body h3 { font-size: 0.95rem; margin: 0 0 0.3rem; }
|
|
783
818
|
.pe-help-body p { margin: 0 0 0.4rem; }
|
|
819
|
+
|
|
820
|
+
/* ------------------------------------------------------------ environment banner, workspace message (091), Administration menu */
|
|
821
|
+
.ide-banner { padding: 3px 12px; font-size: 12px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; text-align: center; }
|
|
822
|
+
.ide-banner-red { background: #b42318; color: #fff; }
|
|
823
|
+
.ide-banner-orange { background: #c4520f; color: #fff; }
|
|
824
|
+
.ide-banner-yellow { background: #e6b400; color: #1e1d1b; }
|
|
825
|
+
.ide-banner-green { background: #2f7d32; color: #fff; }
|
|
826
|
+
.ide-banner-teal { background: #0e7a74; color: #fff; }
|
|
827
|
+
.ide-banner-blue { background: #1a5f99; color: #fff; }
|
|
828
|
+
.ide-banner-purple { background: #6941c6; color: #fff; }
|
|
829
|
+
.ide-banner-gray { background: #5e5a54; color: #fff; }
|
|
830
|
+
.ab-message { display: flex; gap: 0.6rem; align-items: flex-start; margin-bottom: 12px; }
|
|
831
|
+
.ab-message p { margin: 0.15rem 0 0; white-space: pre-line; }
|
|
832
|
+
.rail-admin { padding: 0; }
|
|
833
|
+
.rail-admin > summary { list-style: none; cursor: pointer; }
|
|
834
|
+
.rail-admin > summary::-webkit-details-marker { display: none; }
|
|
835
|
+
.rail-admin .menu-panel { bottom: 56px; }
|
|
836
|
+
.rail-admin .menu-links strong { padding: 2px 0 4px; }
|
|
837
|
+
.ide .alert-info { background: var(--accent-soft); border-color: var(--accent); color: var(--text); }
|
|
838
|
+
|
|
839
|
+
/* ------------------------------------------------------------ SQL Workshop home (sprint 61) */
|
|
840
|
+
.sw-tiles { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
|
841
|
+
.sw-tile-icon { background: #2f7d32; }
|
|
842
|
+
.sw-recents { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
|
|
843
|
+
.sw-recent { min-width: 0; border-left: 1px solid var(--border); }
|
|
844
|
+
.sw-recent:first-child { border-left: 0; }
|
|
845
|
+
.sw-recent h2 { margin: 0; padding: 10px 12px; font-size: 13.5px; border-bottom: 1px solid var(--border); }
|
|
846
|
+
.sw-recent ul { list-style: none; margin: 0; padding: 0; max-height: 440px; overflow-y: auto; }
|
|
847
|
+
.sw-recent li { display: flex; justify-content: space-between; gap: 12px; padding: 7px 12px; border-bottom: 1px solid var(--border); }
|
|
848
|
+
.sw-recent li a { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--gold); text-decoration: none; }
|
|
849
|
+
.sw-recent li a:hover { text-decoration: underline; }
|
|
850
|
+
.sw-recent li .muted { flex: none; font-size: 12px; }
|
|
851
|
+
.sw-recent > p { padding: 0 12px; }
|
|
852
|
+
.sw-schema { display: flex; gap: 6px; }
|
|
853
|
+
.sw-schema select { flex: 1; min-width: 0; }
|
|
854
|
+
@media (max-width: 1100px) { .sw-tiles { grid-template-columns: repeat(auto-fill, minmax(min(100%, 200px), 1fr)); } .sw-recents { grid-template-columns: 1fr; } .sw-recent { border-left: 0; border-top: 1px solid var(--border); } .sw-recent:first-child { border-top: 0; } }
|
|
855
|
+
|
|
856
|
+
/* ------------------------------------------------------------ App Gallery (sprint 61) */
|
|
857
|
+
.gal-bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 8px; }
|
|
858
|
+
.gal-search { flex: 1 1 320px; }
|
|
859
|
+
.gal-tabs { display: inline-flex; }
|
|
860
|
+
.gal-tabs a { padding: 6px 12px; text-decoration: none; color: var(--text); border: 1px solid var(--border); background: var(--surface); }
|
|
861
|
+
.gal-tabs a + a { border-left: 0; }
|
|
862
|
+
.gal-tabs a:first-child { border-radius: var(--radius) 0 0 var(--radius); }
|
|
863
|
+
.gal-tabs a:last-child { border-radius: 0 var(--radius) var(--radius) 0; }
|
|
864
|
+
.gal-tabs a[aria-current] { background: var(--surface-3); font-weight: 650; }
|
|
865
|
+
.gal-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr)); gap: 12px; }
|
|
866
|
+
.gal-card { display: flex; flex-direction: column; gap: 10px; padding: 16px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); min-width: 0; }
|
|
867
|
+
.gal-head { display: flex; gap: 12px; align-items: center; }
|
|
868
|
+
.gal-head h2 { margin: 0; font-size: 15px; }
|
|
869
|
+
.gal-icon { flex: none; display: grid; place-items: center; width: 48px; height: 48px; border-radius: 8px; background: var(--accent-soft); color: var(--accent); }
|
|
870
|
+
.gal-icon .icon { width: 26px; height: 26px; }
|
|
871
|
+
.gal-card > p { margin: 0; flex: 1; line-height: 1.5; }
|
|
872
|
+
.gal-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; }
|
|
873
|
+
.gal-foot form { margin: 0; }
|
|
874
|
+
.ab-group select { min-width: 140px; }
|
package/src/app.ts
CHANGED
|
@@ -66,6 +66,9 @@ import { refreshInstanceSettings } from './instance.ts';
|
|
|
66
66
|
import { loadSecrets, securityHeaders, trustProxySetting } from './security.ts';
|
|
67
67
|
import { lucideSymbol } from './icons.ts';
|
|
68
68
|
import { runtimeForAlias } from './workspace-db.ts';
|
|
69
|
+
import { galleryRoutes } from './builder/gallery.ts';
|
|
70
|
+
import { appGroupRoutes } from './builder/app-groups.ts';
|
|
71
|
+
import { crossAppRoutes } from './builder/cross-app.ts';
|
|
69
72
|
import { runtimeScope } from './db.ts';
|
|
70
73
|
|
|
71
74
|
export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
@@ -171,6 +174,9 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
171
174
|
await app.register(staticFileRoutes);
|
|
172
175
|
await app.register(restRoutes);
|
|
173
176
|
await app.register(builderRoutes);
|
|
177
|
+
await app.register(galleryRoutes);
|
|
178
|
+
await app.register(appGroupRoutes);
|
|
179
|
+
await app.register(crossAppRoutes);
|
|
174
180
|
await app.register(sharedRoutes);
|
|
175
181
|
await app.register(designerRoutes);
|
|
176
182
|
await app.register(lockRoutes);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { owner } from '../db.ts';
|
|
3
|
+
import { html, raw } from '../html.ts';
|
|
4
|
+
import { icon } from '../icons.ts';
|
|
5
|
+
import { clientIp, logActivity } from '../session.ts';
|
|
6
|
+
import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
|
|
7
|
+
import { currentWorkspace } from './workspaces.ts';
|
|
8
|
+
|
|
9
|
+
// Application groups (092; APEX: Workspace Utilities → Application Groups): a
|
|
10
|
+
// workspace's named groups of applications. The App Builder home filters and
|
|
11
|
+
// labels applications by group. Every developer of the workspace manages them.
|
|
12
|
+
|
|
13
|
+
const NAME = /^\S(.{0,58}\S)?$/;
|
|
14
|
+
const crumbs = (extra: [string, string?][] = []): [string, string?][] => [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Application groups', `${BASE}/app-groups`], ...extra];
|
|
15
|
+
|
|
16
|
+
const groupError = (e: unknown) => {
|
|
17
|
+
const m = (e as Error).message;
|
|
18
|
+
if (m.includes('app_group_workspace_id_name_key')) return 'A group with this name exists already.';
|
|
19
|
+
if (m.includes('app_group_name_check')) return 'The name is 1 to 60 characters, without spaces at the start or end.';
|
|
20
|
+
return m;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export async function appGroupRoutes(app: FastifyInstance) {
|
|
24
|
+
app.get(`${BASE}/app-groups`, async (req: Req, reply) => {
|
|
25
|
+
const s = await developer(req, reply);
|
|
26
|
+
if (!s) return;
|
|
27
|
+
const ws = currentWorkspace(s);
|
|
28
|
+
const [groups, apps] = await Promise.all([
|
|
29
|
+
owner.query<{ id: number; name: string; description: string | null; apps: number }>(
|
|
30
|
+
`select g.id, g.name, g.description, (select count(*) from meta.app_group_member m where m.group_id = g.id)::int as apps
|
|
31
|
+
from meta.app_group g where g.workspace_id = $1 order by lower(g.name)`,
|
|
32
|
+
[ws],
|
|
33
|
+
),
|
|
34
|
+
owner.query<{ id: number; name: string; alias: string; group_id: number | null }>(
|
|
35
|
+
`select a.id, a.name, a.alias, m.group_id from meta.app a left join meta.app_group_member m on m.app_id = a.id
|
|
36
|
+
where meta.app_workspace(a.id) = $1 order by lower(a.name), a.id`,
|
|
37
|
+
[ws],
|
|
38
|
+
),
|
|
39
|
+
]);
|
|
40
|
+
const main = html`<div class="title-row"><h1>Application groups</h1></div>
|
|
41
|
+
<p class="muted u-mt0">Groups organise the applications of this workspace (${s.workspace?.name ?? ''}) on the App Builder home, which can show one group at a time. They change nothing in the applications.</p>
|
|
42
|
+
<div class="columns wide-left">
|
|
43
|
+
${region('Groups', groups.rows.length
|
|
44
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Group</th><th class="num">Applications</th><th>Rename</th><th></th></tr></thead><tbody>
|
|
45
|
+
${groups.rows.map((g) => html`<tr>
|
|
46
|
+
<td data-label="Group"><a href="${BASE}?group=${g.id}">${g.name}</a>${g.description ? html`<br><span class="muted small">${g.description}</span>` : ''}</td>
|
|
47
|
+
<td class="num" data-label="Applications">${g.apps}</td>
|
|
48
|
+
<td data-label="Rename"><form method="post" action="${BASE}/app-groups/${g.id}" class="search u-mwnone">${csrf(s)}
|
|
49
|
+
<label class="sr-only" for="g-${g.id}">New name for ${g.name}</label><input id="g-${g.id}" name="name" value="${g.name}" required maxlength="60">
|
|
50
|
+
<button class="btn btn-sm">Rename</button></form></td>
|
|
51
|
+
<td><form method="post" action="${BASE}/app-groups/${g.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="Delete the group ${g.name}? Its applications stay, without a group.">${icon('trash')}<span class="sr-only">Delete ${g.name}</span></button></form></td></tr>`)}
|
|
52
|
+
</tbody></table></div>`
|
|
53
|
+
: html`<p class="muted">No groups yet.</p>`)}
|
|
54
|
+
${region('Add a group', html`<form method="post" action="${BASE}/app-groups">${csrf(s)}
|
|
55
|
+
${input('name', 'Name', '', { required: true, placeholder: 'Finance' })}
|
|
56
|
+
${input('description', 'Description', '')}
|
|
57
|
+
<div class="buttons"><button class="btn btn-hot">${icon('plus')} Add group</button></div></form>`)}
|
|
58
|
+
</div>
|
|
59
|
+
<div class="u-spacer"></div>
|
|
60
|
+
${region('Applications', apps.rows.length
|
|
61
|
+
? html`<form method="post" action="${BASE}/app-groups/assign">${csrf(s)}
|
|
62
|
+
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Application</th><th>Group</th></tr></thead><tbody>
|
|
63
|
+
${apps.rows.map((a) => html`<tr><td data-label="Application"><a href="${BASE}/apps/${a.id}">${a.name}</a> <span class="muted">/a/${a.alias}</span></td>
|
|
64
|
+
<td data-label="Group"><label class="sr-only" for="a-${a.id}">Group of ${a.name}</label>
|
|
65
|
+
<select id="a-${a.id}" name="app_${a.id}"><option value="">(no group)</option>${groups.rows.map((g) => html`<option value="${g.id}"${a.group_id === g.id ? raw(' selected') : ''}>${g.name}</option>`)}</select></td></tr>`)}
|
|
66
|
+
</tbody></table></div>
|
|
67
|
+
<div class="buttons"><button class="btn"${groups.rows.length ? '' : raw(' disabled')}>Save groups</button></div></form>`
|
|
68
|
+
: html`<p class="muted">No applications in this workspace.</p>`)}`;
|
|
69
|
+
return send(reply, s, shell(s, 'Application groups', crumbs(), main));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
app.post(`${BASE}/app-groups`, async (req: Req, reply) => {
|
|
73
|
+
const s = await developer(req, reply);
|
|
74
|
+
if (!s) return;
|
|
75
|
+
const name = String(req.body?.name ?? '').trim();
|
|
76
|
+
try {
|
|
77
|
+
if (!NAME.test(name)) throw new Error('The name is 1 to 60 characters, without spaces at the start or end.');
|
|
78
|
+
await owner.query('insert into meta.app_group (workspace_id, name, description) values ($1, $2, $3)', [currentWorkspace(s), name, String(req.body?.description ?? '').trim().slice(0, 2000) || null]);
|
|
79
|
+
await logActivity({ username: s.username, event: 'app_group', ip: clientIp(req), detail: `added ${name}` });
|
|
80
|
+
flash(s, `Group ${name} added.`);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
flash(s, groupError(e), 'error');
|
|
83
|
+
}
|
|
84
|
+
return back(reply, s, `${BASE}/app-groups`);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
app.post(`${BASE}/app-groups/:id(^\\d{1,9}$)`, async (req: Req, reply) => {
|
|
88
|
+
const s = await developer(req, reply);
|
|
89
|
+
if (!s) return;
|
|
90
|
+
const name = String(req.body?.name ?? '').trim();
|
|
91
|
+
try {
|
|
92
|
+
if (!NAME.test(name)) throw new Error('The name is 1 to 60 characters, without spaces at the start or end.');
|
|
93
|
+
const r = await owner.query('update meta.app_group set name = $3 where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s), name]);
|
|
94
|
+
if (!r.rowCount) return reply.code(404).send('Not found');
|
|
95
|
+
flash(s, `Group renamed to ${name}.`);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
flash(s, groupError(e), 'error');
|
|
98
|
+
}
|
|
99
|
+
return back(reply, s, `${BASE}/app-groups`);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
app.post(`${BASE}/app-groups/:id(^\\d{1,9}$)/delete`, async (req: Req, reply) => {
|
|
103
|
+
const s = await developer(req, reply);
|
|
104
|
+
if (!s) return;
|
|
105
|
+
const r = await owner.one<{ name: string }>('delete from meta.app_group where id = $1 and workspace_id = $2 returning name', [req.params.id, currentWorkspace(s)]);
|
|
106
|
+
if (!r) return reply.code(404).send('Not found');
|
|
107
|
+
await logActivity({ username: s.username, event: 'app_group', ip: clientIp(req), detail: `deleted ${r.name}` });
|
|
108
|
+
flash(s, `Group ${r.name} deleted.`);
|
|
109
|
+
return back(reply, s, `${BASE}/app-groups`);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// the group of each application of the workspace (only this workspace's groups and applications)
|
|
113
|
+
app.post(`${BASE}/app-groups/assign`, async (req: Req, reply) => {
|
|
114
|
+
const s = await developer(req, reply);
|
|
115
|
+
if (!s) return;
|
|
116
|
+
const ws = currentWorkspace(s);
|
|
117
|
+
const pairs = Object.entries(req.body ?? {}).flatMap(([k, v]) => {
|
|
118
|
+
const m = /^app_(\d{1,9})$/.exec(k);
|
|
119
|
+
return m ? [[Number(m[1]), /^\d{1,9}$/.test(String(v)) ? Number(v) : null] as [number, number | null]] : [];
|
|
120
|
+
}).slice(0, 5000);
|
|
121
|
+
await owner.tx(async (c) => {
|
|
122
|
+
for (const [appId, groupId] of pairs) {
|
|
123
|
+
if (!(await c.query('select 1 from meta.app where id = $1 and meta.app_workspace(id) = $2', [appId, ws])).rowCount) continue;
|
|
124
|
+
if (groupId === null) await c.query('delete from meta.app_group_member where app_id = $1', [appId]);
|
|
125
|
+
else if ((await c.query('select 1 from meta.app_group where id = $1 and workspace_id = $2', [groupId, ws])).rowCount)
|
|
126
|
+
await c.query('insert into meta.app_group_member (app_id, group_id) values ($1, $2) on conflict (app_id) do update set group_id = excluded.group_id', [appId, groupId]);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
flash(s, 'Groups saved.');
|
|
130
|
+
return back(reply, s, `${BASE}/app-groups`);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -129,6 +129,20 @@ function review(s: Session, bp: Blueprint, spec: string, id: number | null, name
|
|
|
129
129
|
</form>`)}`;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* (sprint 61) Open a blueprint (e.g. a starter app of the App Gallery): its review when it can be
|
|
134
|
+
* created, else the editor with its problems. Nothing is created before the developer confirms.
|
|
135
|
+
*/
|
|
136
|
+
export async function openBlueprint(s: Session, reply: FastifyReply, name: string, spec: string) {
|
|
137
|
+
const { json, error } = parse(spec);
|
|
138
|
+
const { blueprint, problems } = error ? { blueprint: null, problems: [error] } : checkBlueprint(json);
|
|
139
|
+
const taken = blueprint ? await owner.one('select 1 from meta.app where alias = $1', [blueprint.alias]) : null;
|
|
140
|
+
if (taken) problems.push(`An application with the alias ${blueprint!.alias} already exists: change "alias" and "schema" in the blueprint.`);
|
|
141
|
+
if (!blueprint || problems.length)
|
|
142
|
+
return editor(s, reply, { name, spec, message: html`<div class="alert alert-error" role="alert"><p>The blueprint can't be created yet:</p><ul>${problems.map((p) => html`<li>${p}</li>`)}</ul></div>` });
|
|
143
|
+
return page(s, reply, 'Review', review(s, blueprint, spec, null, name));
|
|
144
|
+
}
|
|
145
|
+
|
|
132
146
|
export async function blueprintRoutes(app: FastifyInstance) {
|
|
133
147
|
app.get(`${BASE}/blueprints`, async (req: Req, reply) => {
|
|
134
148
|
const s = await developer(req, reply);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { owner } from '../db.ts';
|
|
3
|
+
import { html, raw } from '../html.ts';
|
|
4
|
+
import { icon } from '../icons.ts';
|
|
5
|
+
import { BASE, developer, region, send, shell, type Req } from './ui.ts';
|
|
6
|
+
import { currentWorkspace } from './workspaces.ts';
|
|
7
|
+
|
|
8
|
+
// Cross-application reports (sprint 61; APEX: Workspace Utilities → Cross
|
|
9
|
+
// Application Reports): one read-only report per topic over every application
|
|
10
|
+
// of the current workspace. Each query takes the workspace id as $1 and
|
|
11
|
+
// returns app_id and app (the name) first; those become a link.
|
|
12
|
+
|
|
13
|
+
interface Report {
|
|
14
|
+
title: string;
|
|
15
|
+
about: string;
|
|
16
|
+
columns: [key: string, label: string, cls?: 'num'][];
|
|
17
|
+
sql: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The current workspace's applications (alias a). */
|
|
21
|
+
const IN_WS = 'meta.app_workspace(a.id) = $1';
|
|
22
|
+
|
|
23
|
+
export const REPORTS: Record<string, Report> = {
|
|
24
|
+
attributes: {
|
|
25
|
+
title: 'Application attributes',
|
|
26
|
+
about: 'The main settings of every application: type, sign-in, availability, build status, debug level, language and database role.',
|
|
27
|
+
columns: [['alias', 'Alias'], ['app_type', 'Type'], ['authentication', 'Sign-in'], ['availability', 'Availability'], ['build_status', 'Build status'], ['debug_level', 'Debug', 'num'], ['language', 'Language'], ['db_role', 'Database role'], ['updated', 'Updated']],
|
|
28
|
+
sql: `select a.id as app_id, a.name as app, a.alias, a.app_type, a.authentication, a.availability, a.build_status, a.debug_level,
|
|
29
|
+
a.language, coalesce(a.db_role, '(runtime connection)') as db_role, to_char(a.updated_at, 'YYYY-MM-DD HH24:MI') as updated
|
|
30
|
+
from meta.app a where ${IN_WS} order by lower(a.name)`,
|
|
31
|
+
},
|
|
32
|
+
'build-options': {
|
|
33
|
+
title: 'Build options',
|
|
34
|
+
about: 'The build options of every application and whether their components are included.',
|
|
35
|
+
columns: [['name', 'Build option'], ['status', 'Status'], ['description', 'Description']],
|
|
36
|
+
sql: `select a.id as app_id, a.name as app, b.name, b.status, b.description
|
|
37
|
+
from meta.app a join meta.build_option b on b.app_id = a.id where ${IN_WS} order by lower(a.name), b.name`,
|
|
38
|
+
},
|
|
39
|
+
comments: {
|
|
40
|
+
title: 'Developer comments',
|
|
41
|
+
about: 'The developers\' comments on applications and pages, newest first.',
|
|
42
|
+
columns: [['page_no', 'Page', 'num'], ['author', 'Author'], ['created', 'Written'], ['body', 'Comment']],
|
|
43
|
+
sql: `select a.id as app_id, a.name as app, c.page_no, c.author, to_char(c.created_at, 'YYYY-MM-DD HH24:MI') as created, left(c.body, 500) as body
|
|
44
|
+
from meta.app a join meta.dev_comment c on c.app_id = a.id where ${IN_WS} order by c.created_at desc limit 500`,
|
|
45
|
+
},
|
|
46
|
+
authentication: {
|
|
47
|
+
title: 'Authentication',
|
|
48
|
+
about: 'How people sign in to every application: the sign-in method, password sign-in, identity providers and LDAP directories, access control, the login page and Forgot password.',
|
|
49
|
+
columns: [['authentication', 'Sign-in'], ['local_login', 'Passwords'], ['providers', 'Identity providers'], ['ldap', 'LDAP directories'], ['access_control', 'Access'], ['remember_me_days', 'Keep me signed in (days)', 'num'], ['login_page', 'Login page', 'num'], ['password_reset', 'Forgot password']],
|
|
50
|
+
sql: `select a.id as app_id, a.name as app, a.authentication, case when a.local_login then 'yes' else 'no' end as local_login,
|
|
51
|
+
array_to_string(a.sso_providers, ', ') as providers, array_to_string(a.ldap_directories, ', ') as ldap,
|
|
52
|
+
a.access_control, a.remember_me_days, a.login_page, case when a.password_reset then 'yes' else 'no' end as password_reset
|
|
53
|
+
from meta.app a where ${IN_WS} order by lower(a.name)`,
|
|
54
|
+
},
|
|
55
|
+
authorization: {
|
|
56
|
+
title: 'Authorization schemes',
|
|
57
|
+
about: 'The authorization schemes of every application: their type and what they check.',
|
|
58
|
+
columns: [['name', 'Scheme'], ['type', 'Type'], ['value', 'Checks'], ['error_message', 'Error message']],
|
|
59
|
+
sql: `select a.id as app_id, a.name as app, s.name, s.type, left(s.value, 300) as value, s.error_message
|
|
60
|
+
from meta.app a join meta.authz_scheme s on s.app_id = a.id where ${IN_WS} order by lower(a.name), s.name`,
|
|
61
|
+
},
|
|
62
|
+
'password-items': {
|
|
63
|
+
title: 'Password items',
|
|
64
|
+
about: 'Every password item: pgkiln never shows their values again, never keeps them in session state for the next page and never writes them to the debug log.',
|
|
65
|
+
columns: [['page_no', 'Page', 'num'], ['name', 'Item'], ['label', 'Label'], ['reveal', 'Show button'], ['required', 'Required']],
|
|
66
|
+
sql: `select a.id as app_id, a.name as app, p.page_no, i.name, i.label,
|
|
67
|
+
case when (i.config ->> 'reveal')::boolean then 'yes' else 'no' end as reveal, case when i.required then 'yes' else 'no' end as required
|
|
68
|
+
from meta.app a join meta.page p on p.app_id = a.id join meta.item i on i.page_id = p.id
|
|
69
|
+
where ${IN_WS} and i.type = 'password' order by lower(a.name), p.page_no, i.seq`,
|
|
70
|
+
},
|
|
71
|
+
theme: {
|
|
72
|
+
title: 'Theme summary',
|
|
73
|
+
about: 'The look of every application: base style, Theme Roller style, navigation and the colours it sets.',
|
|
74
|
+
columns: [['base', 'Base style'], ['style', 'Style'], ['nav', 'Navigation'], ['colors', 'Colours set', 'num'], ['pwa', 'Installable (PWA)']],
|
|
75
|
+
sql: `select a.id as app_id, a.name as app, coalesce(a.theme ->> 'base', 'default') as base, coalesce(a.theme ->> 'style', '-') as style,
|
|
76
|
+
coalesce(a.theme ->> 'nav', 'side') as nav,
|
|
77
|
+
(select count(*) from jsonb_object_keys(coalesce(a.theme -> 'colors', '{}'::jsonb)))::int as colors,
|
|
78
|
+
case when a.pwa then 'yes' else 'no' end as pwa
|
|
79
|
+
from meta.app a where ${IN_WS} order by lower(a.name)`,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** The links in Workspace utilities' side column. */
|
|
84
|
+
export const crossAppLinks = () =>
|
|
85
|
+
Object.entries(REPORTS).map(([k, r]) => html`<li><a href="${BASE}/utilities/reports/${k}">${r.title}${icon('chevron')}</a></li>`);
|
|
86
|
+
|
|
87
|
+
export async function crossAppRoutes(app: FastifyInstance) {
|
|
88
|
+
app.get(`${BASE}/utilities/reports/:kind`, async (req: Req, reply) => {
|
|
89
|
+
const s = await developer(req, reply);
|
|
90
|
+
if (!s) return;
|
|
91
|
+
const r = Object.hasOwn(REPORTS, req.params.kind) ? REPORTS[req.params.kind] : undefined;
|
|
92
|
+
if (!r) return reply.code(404).send('Not found');
|
|
93
|
+
const rows = (await owner.query(r.sql, [currentWorkspace(s)])).rows;
|
|
94
|
+
const cell = (v: unknown) => (v === null || v === undefined || v === '' ? html`<span class="muted">-</span>` : String(v));
|
|
95
|
+
const main = html`<div class="title-row"><h1>${r.title}</h1></div>
|
|
96
|
+
<p class="muted u-mt0">${r.about} Applications of the workspace ${s.workspace?.name ?? ''}.</p>
|
|
97
|
+
<nav class="ide-tabs u-mb1" aria-label="Cross-application reports">${Object.entries(REPORTS).map(([k, x]) =>
|
|
98
|
+
html`<a class="ide-tab" href="${BASE}/utilities/reports/${k}"${k === req.params.kind ? raw(' aria-current="page"') : ''}><span>${x.title}</span></a>`)}</nav>
|
|
99
|
+
${region(r.title, rows.length
|
|
100
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Application</th>${r.columns.map(([, l, c]) => html`<th${c ? raw(` class="${c}"`) : ''}>${l}</th>`)}</tr></thead>
|
|
101
|
+
<tbody>${rows.map((row) => html`<tr><td data-label="Application"><a href="${BASE}/apps/${row.app_id}">${row.app}</a></td>
|
|
102
|
+
${r.columns.map(([k, l, c]) => html`<td data-label="${l}"${c ? raw(` class="${c}"`) : ''}>${cell(row[k])}</td>`)}</tr>`)}</tbody></table></div>`
|
|
103
|
+
: html`<p class="muted">Nothing to report in this workspace.</p>`, html`<span class="count">${rows.length}</span>`)}`;
|
|
104
|
+
return send(reply, s, shell(s, r.title, [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], [r.title]], main));
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { FastifyInstance } from 'fastify';
|
|
4
|
+
import { owner, ownerUrl } from '../db.ts';
|
|
5
|
+
import { root } from '../env.ts';
|
|
6
|
+
import { html, raw } from '../html.ts';
|
|
7
|
+
import { icon } from '../icons.ts';
|
|
8
|
+
import { migrate } from '../migrate.ts';
|
|
9
|
+
import { clientIp, logActivity } from '../session.ts';
|
|
10
|
+
import { openBlueprint } from './blueprints.ts';
|
|
11
|
+
import { isAdmin } from './locks.ts';
|
|
12
|
+
import { back, BASE, csrf, developer, flash, send, shell, type Req } from './ui.ts';
|
|
13
|
+
import { currentWorkspace, inWorkspaceSql, placeApp } from './workspaces.ts';
|
|
14
|
+
import { workspacesChanged } from '../workspace-db.ts';
|
|
15
|
+
|
|
16
|
+
// The App Gallery (sprint 61; APEX: App Gallery with Sample, Starter and Custom Apps).
|
|
17
|
+
//
|
|
18
|
+
// Sample apps applications shipped with pgkiln (examples/): an administrator installs
|
|
19
|
+
// one with its schema, data and demo accounts, as `pgkiln migrate --example`;
|
|
20
|
+
// Starter apps blueprints shipped with pgkiln (examples/gallery/starters/): Install opens
|
|
21
|
+
// the blueprint's review, and the developer creates the application from it
|
|
22
|
+
// in the current workspace (a new role and schema, like every new application);
|
|
23
|
+
// Custom apps the workspace's boilerplate applications: Create application starts from one.
|
|
24
|
+
//
|
|
25
|
+
// The list is examples/gallery/gallery.json.
|
|
26
|
+
|
|
27
|
+
interface Sample {
|
|
28
|
+
key: string;
|
|
29
|
+
title: string;
|
|
30
|
+
alias: string;
|
|
31
|
+
icon: string;
|
|
32
|
+
description: string;
|
|
33
|
+
example?: string;
|
|
34
|
+
file?: string;
|
|
35
|
+
}
|
|
36
|
+
interface Starter {
|
|
37
|
+
key: string;
|
|
38
|
+
title: string;
|
|
39
|
+
icon: string;
|
|
40
|
+
description: string;
|
|
41
|
+
file: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const GALLERY = join(root, 'examples', 'gallery');
|
|
45
|
+
const KEY = /^[a-z][a-z0-9-]{0,40}$/;
|
|
46
|
+
|
|
47
|
+
function catalog(): { samples: Sample[]; starters: Starter[] } {
|
|
48
|
+
try {
|
|
49
|
+
const c = JSON.parse(readFileSync(join(GALLERY, 'gallery.json'), 'utf8'));
|
|
50
|
+
return { samples: Array.isArray(c.samples) ? c.samples : [], starters: Array.isArray(c.starters) ? c.starters : [] };
|
|
51
|
+
} catch {
|
|
52
|
+
return { samples: [], starters: [] };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A file of the gallery: only names from the catalogue, never a path from the request. */
|
|
57
|
+
const galleryFile = (rel: string) => readFileSync(join(rel.includes('/') ? GALLERY : join(root, 'examples'), rel), 'utf8');
|
|
58
|
+
|
|
59
|
+
type Tab = 'samples' | 'starters' | 'custom';
|
|
60
|
+
|
|
61
|
+
export async function galleryRoutes(app: FastifyInstance) {
|
|
62
|
+
app.get(`${BASE}/gallery`, async (req: Req, reply) => {
|
|
63
|
+
const s = await developer(req, reply);
|
|
64
|
+
if (!s) return;
|
|
65
|
+
const tab: Tab = (['samples', 'starters', 'custom'] as const).find((t) => t === req.query?.tab) ?? 'samples';
|
|
66
|
+
const admin = await isAdmin(s.username);
|
|
67
|
+
const { samples, starters } = catalog();
|
|
68
|
+
const installed = new Map(
|
|
69
|
+
(await owner.query<{ alias: string; id: number }>('select alias, id from meta.app where alias = any($1)', [samples.map((x) => x.alias)])).rows.map((r) => [r.alias, r.id]),
|
|
70
|
+
);
|
|
71
|
+
const custom = (await owner.query<{ id: number; name: string; description: string | null }>(
|
|
72
|
+
`select a.id, a.name, null::text as description from meta.app a where a.app_type = 'boilerplate' and ${inWorkspaceSql('a', 1)} order by lower(a.name), a.id`,
|
|
73
|
+
[currentWorkspace(s)],
|
|
74
|
+
)).rows;
|
|
75
|
+
const card = (ic: string, title: string, by: string, text: string, action: unknown) =>
|
|
76
|
+
html`<article class="gal-card" data-filter-row><div class="gal-head"><span class="gal-icon">${icon(ic)}</span><div><h2>${title}</h2><span class="muted small">${by}</span></div></div>
|
|
77
|
+
<p>${text}</p><div class="gal-foot">${action}</div></article>`;
|
|
78
|
+
const tabs = html`<nav class="gal-tabs segmented" aria-label="Kinds of applications">${([['samples', 'Sample Apps'], ['starters', 'Starter Apps'], ['custom', 'Custom Apps']] as const).map(([t, l]) =>
|
|
79
|
+
html`<a href="${BASE}/gallery?tab=${t}"${tab === t ? raw(' aria-current="page"') : ''}>${l}</a>`)}</nav>`;
|
|
80
|
+
const cards =
|
|
81
|
+
tab === 'samples'
|
|
82
|
+
? samples.map((x) => card(x.icon, x.title, 'pgkiln', x.description,
|
|
83
|
+
installed.has(x.alias)
|
|
84
|
+
? html`<span class="tag tag-ok">Installed</span><a class="btn btn-sm" href="/a/${x.alias}" target="_blank" rel="noopener">Run</a><a class="btn btn-sm" href="${BASE}/apps/${installed.get(x.alias)}">Edit</a>`
|
|
85
|
+
: admin
|
|
86
|
+
? html`<form method="post" action="${BASE}/gallery/samples/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm" data-confirm="Install ${x.title}? It adds its own schema, data and demo accounts with known passwords: not for a production instance.">Install</button></form>`
|
|
87
|
+
: html`<span class="muted small">An administrator installs it.</span>`))
|
|
88
|
+
: tab === 'starters'
|
|
89
|
+
? starters.map((x) => card(x.icon, x.title, 'pgkiln', x.description,
|
|
90
|
+
html`<form method="post" action="${BASE}/gallery/starters/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm">Install</button></form>`))
|
|
91
|
+
: custom.map((x) => card('copy', x.name, 'This workspace', x.description ?? 'A boilerplate application: a new application starts with its pages, shared components and settings.',
|
|
92
|
+
html`<a class="btn btn-hot btn-sm" href="${BASE}/create?boilerplate=${x.id}">Create from it</a><a class="btn btn-sm" href="${BASE}/apps/${x.id}">Edit</a>`));
|
|
93
|
+
const empty = tab === 'custom'
|
|
94
|
+
? html`<p class="muted">No custom apps yet: make an application of type <em>Boilerplate</em> (Application settings → Application type) and it appears here.</p>`
|
|
95
|
+
: html`<p class="muted">The gallery is empty.</p>`;
|
|
96
|
+
const intro = {
|
|
97
|
+
samples: 'Applications that show what pgkiln can do, with their own data and demo accounts. Install them on a development or demo instance.',
|
|
98
|
+
starters: 'Small, ready-to-use applications. Install shows what will be created (tables, pages, sample data) before anything happens; the application gets its own role and schema in this workspace.',
|
|
99
|
+
custom: 'The boilerplate applications of this workspace: start a new application from one.',
|
|
100
|
+
}[tab];
|
|
101
|
+
const main = html`<div class="gal">
|
|
102
|
+
<div class="gal-bar">
|
|
103
|
+
<div class="ab-search gal-search" role="search"><label class="sr-only" for="gal-q">Search apps</label>${icon('search')}
|
|
104
|
+
<input id="gal-q" type="search" placeholder="Search apps" autocomplete="off" data-filter-list=".gal [data-filter-row]"></div>
|
|
105
|
+
${tabs}
|
|
106
|
+
</div>
|
|
107
|
+
<h1 class="sr-only">App Gallery</h1>
|
|
108
|
+
<p class="muted">${intro}</p>
|
|
109
|
+
${cards.length ? html`<div class="gal-grid">${cards}</div>` : empty}
|
|
110
|
+
</div>`;
|
|
111
|
+
return send(reply, s, shell(s, 'App Gallery', [['App Gallery']], main, 'gallery'));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// a sample app: an administrator installs it (shipped SQL, run as the owner)
|
|
115
|
+
app.post(`${BASE}/gallery/samples/:key`, async (req: Req, reply) => {
|
|
116
|
+
const s = await developer(req, reply);
|
|
117
|
+
if (!s) return;
|
|
118
|
+
if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators install sample apps.');
|
|
119
|
+
const x = KEY.test(req.params.key) ? catalog().samples.find((c) => c.key === req.params.key) : undefined;
|
|
120
|
+
if (!x) return reply.code(404).send('Not found');
|
|
121
|
+
try {
|
|
122
|
+
if (await owner.one('select 1 as ok from meta.app where alias = $1', [x.alias])) throw new Error(`an application with the alias ${x.alias} exists already.`);
|
|
123
|
+
if (x.example && KEY.test(x.example)) await migrate({ root, databaseUrl: ownerUrl, example: x.example, log: () => {} });
|
|
124
|
+
else if (x.file && /^[a-z0-9-]+\.sql$/.test(x.file)) await owner.query(galleryFile(x.file));
|
|
125
|
+
else throw new Error('the gallery entry names nothing to install.');
|
|
126
|
+
const made = await owner.one<{ id: number; db_role: string | null }>('select id, db_role from meta.app where alias = $1', [x.alias]);
|
|
127
|
+
if (!made) throw new Error(`the scripts ran, but there is no application ${x.alias}.`);
|
|
128
|
+
// into the current workspace (an isolated one gets the application's role too)
|
|
129
|
+
const ws = currentWorkspace(s);
|
|
130
|
+
if (ws > 1)
|
|
131
|
+
await owner.tx(async (c) => {
|
|
132
|
+
if (made.db_role && (await c.query('select 1 from meta.workspace where id = $1 and isolated', [ws])).rowCount)
|
|
133
|
+
await c.query('insert into meta.workspace_role (workspace_id, role_name) values ($1, $2) on conflict do nothing', [ws, made.db_role]);
|
|
134
|
+
await placeApp(c, made.id, ws);
|
|
135
|
+
});
|
|
136
|
+
await workspacesChanged();
|
|
137
|
+
await logActivity({ appId: made.id, username: s.username, event: 'gallery', ip: clientIp(req), detail: `installed sample app ${x.key}` });
|
|
138
|
+
flash(s, `${x.title} installed (/a/${x.alias}).`);
|
|
139
|
+
return back(reply, s, `${BASE}/apps/${made.id}`);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
flash(s, `${x.title} was not installed: ${(e as Error).message}`, 'error');
|
|
142
|
+
return back(reply, s, `${BASE}/gallery`);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// a starter app: its blueprint's review (nothing is created before the developer confirms there)
|
|
147
|
+
app.post(`${BASE}/gallery/starters/:key`, async (req: Req, reply) => {
|
|
148
|
+
const s = await developer(req, reply);
|
|
149
|
+
if (!s) return;
|
|
150
|
+
const x = KEY.test(req.params.key) ? catalog().starters.find((c) => c.key === req.params.key) : undefined;
|
|
151
|
+
if (!x || !/^starters\/[a-z0-9-]+\.json$/.test(x.file)) return reply.code(404).send('Not found');
|
|
152
|
+
return openBlueprint(s, reply, x.title, JSON.stringify(JSON.parse(galleryFile(x.file)), null, 2));
|
|
153
|
+
});
|
|
154
|
+
}
|