pgkiln 0.46.0 → 0.48.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/108_sql_history.sql +25 -0
- package/db/migrations/109_builder_pins.sql +19 -0
- package/db/migrations/110_sql_charts.sql +23 -0
- package/db/migrations/111_sql_diagram.sql +24 -0
- package/db/migrations/113_rest_objects.sql +49 -0
- package/docs/apex-feature-parity.md +28 -2
- package/docs/guide/03-builder.md +178 -21
- package/docs/guide/08-security.md +19 -1
- package/docs/guide/10-tutorial.md +1 -1
- package/docs/guide/12-development.md +46 -2
- package/docs/guide/13-rest-api.md +74 -9
- package/docs/guide/16-files.md +2 -2
- package/package.json +1 -1
- package/public/builder.css +22 -0
- package/public/data-modeler.css +56 -0
- package/public/data-modeler.js +367 -0
- package/public/palette.js +133 -0
- package/public/worksheet.css +293 -0
- package/public/worksheet.js +669 -0
- package/src/app.ts +4 -0
- package/src/builder/ai-builder.ts +3 -3
- package/src/builder/autorest.ts +362 -0
- package/src/builder/data-modeler.ts +382 -0
- package/src/builder/db-users.ts +472 -0
- package/src/builder/launchpad.ts +140 -0
- package/src/builder/object-actions.ts +167 -0
- package/src/builder/object-browser.ts +47 -26
- package/src/builder/querybuilder.ts +1 -1
- package/src/builder/sampledata.ts +2 -2
- package/src/builder/scripts.ts +3 -3
- package/src/builder/sql-charts.ts +203 -0
- package/src/builder/sql-tools.ts +49 -0
- package/src/builder/sql.ts +28 -81
- package/src/builder/table-editor.ts +219 -0
- package/src/builder/ui.ts +21 -15
- package/src/builder/unload.ts +8 -4
- package/src/builder/worksheet.ts +592 -0
- package/src/builder/workspaces.ts +5 -3
- package/src/cli/replace.ts +3 -1
- package/src/datamodel.ts +324 -0
- package/src/dbroles.ts +215 -0
- package/src/runtime/autorest.ts +336 -0
- package/src/runtime/rest.ts +4 -4
- package/src/session.ts +2 -0
- package/src/sqlformat.ts +208 -0
- package/src/sqlplan.ts +133 -0
- package/src/sqlscript.ts +6 -2
- package/src/sqlworksheet.ts +278 -0
- package/src/tabledef.ts +226 -0
- package/src/unload.ts +14 -3
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
|
+
import { owner, runtimeUrl } from '../db.ts';
|
|
3
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
4
|
+
import { icon } from '../icons.ts';
|
|
5
|
+
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
6
|
+
import {
|
|
7
|
+
checkPassword, checkRole, dropSql, emptyRole, maskSql, reviewDigest, roleSql, scramVerifier, withVerifier,
|
|
8
|
+
type DefaultPriv, type ExistingPriv, type RoleContext, type RoleSpec, type SchemaGrant,
|
|
9
|
+
} from '../dbroles.ts';
|
|
10
|
+
import { administrator, back, BASE, csrf, flash, region, send, shell, type Body, type Req } from './ui.ts';
|
|
11
|
+
|
|
12
|
+
// (sprint 99, Database Actions DA-13) Administration → Database Users: the PostgreSQL roles of this cluster with
|
|
13
|
+
// their sign-in, attributes, expiry, memberships and what they own; create, change and drop roles. Administrators
|
|
14
|
+
// only. Every change is shown as SQL first (the password masked) and applied in one transaction. pgkiln's own roles
|
|
15
|
+
// (the owner and runtime connections, pgkiln_*, the bootstrap superuser) can't be changed or dropped here; roles
|
|
16
|
+
// applications and isolated workspaces use can be changed but not dropped. SQL generation: src/dbroles.ts.
|
|
17
|
+
|
|
18
|
+
const URL_BASE = `${BASE}/db-users`;
|
|
19
|
+
const TITLE = 'Database Users';
|
|
20
|
+
/** predefined roles that reach the server's files or programs: not granted from here */
|
|
21
|
+
const SERVER_ROLES = ['pg_database_owner', 'pg_execute_server_program', 'pg_read_server_files', 'pg_write_server_files'];
|
|
22
|
+
const roleUrl = (name: string) => `${URL_BASE}/role/${encodeURIComponent(name)}`;
|
|
23
|
+
const runtimeUser = (() => {
|
|
24
|
+
try {
|
|
25
|
+
return decodeURIComponent(new URL(runtimeUrl).username);
|
|
26
|
+
} catch {
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
})();
|
|
30
|
+
|
|
31
|
+
interface RoleRow {
|
|
32
|
+
oid: number;
|
|
33
|
+
name: string;
|
|
34
|
+
login: boolean;
|
|
35
|
+
superuser: boolean;
|
|
36
|
+
createdb: boolean;
|
|
37
|
+
createrole: boolean;
|
|
38
|
+
bypassrls: boolean;
|
|
39
|
+
replication: boolean;
|
|
40
|
+
conn_limit: number;
|
|
41
|
+
valid_until: string | null;
|
|
42
|
+
days_left: number | null;
|
|
43
|
+
member_of: string[];
|
|
44
|
+
schemas: string[];
|
|
45
|
+
owned: number;
|
|
46
|
+
owned_elsewhere: number;
|
|
47
|
+
comment: string | null;
|
|
48
|
+
pgkiln: boolean;
|
|
49
|
+
used_by: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ROLES_SQL = `
|
|
53
|
+
with here as (select oid from pg_database where datname = current_database()),
|
|
54
|
+
own as (select d.refobjid as role, count(*) filter (where d.dbid = (select oid from here))::int as here,
|
|
55
|
+
count(*) filter (where d.dbid <> (select oid from here))::int as elsewhere
|
|
56
|
+
from pg_shdepend d where d.refclassid = 'pg_authid'::regclass and d.deptype = 'o' group by 1)
|
|
57
|
+
select r.oid::int as oid, r.rolname as name, r.rolcanlogin as login, r.rolsuper as superuser, r.rolcreatedb as createdb,
|
|
58
|
+
r.rolcreaterole as createrole, r.rolbypassrls as bypassrls, r.rolreplication as replication, r.rolconnlimit as conn_limit,
|
|
59
|
+
case when r.rolvaliduntil is null or r.rolvaliduntil = 'infinity' then null else to_char(r.rolvaliduntil, 'YYYY-MM-DD') end as valid_until,
|
|
60
|
+
case when r.rolvaliduntil is null or r.rolvaliduntil = 'infinity' then null else floor(extract(epoch from r.rolvaliduntil - now()) / 86400)::int end as days_left,
|
|
61
|
+
array(select distinct g.rolname::text from pg_auth_members m join pg_roles g on g.oid = m.roleid where m.member = r.oid order by 1) as member_of,
|
|
62
|
+
array(select n.nspname::text from pg_namespace n where n.nspowner = r.oid order by 1) as schemas,
|
|
63
|
+
coalesce(o.here, 0) as owned, coalesce(o.elsewhere, 0) as owned_elsewhere,
|
|
64
|
+
shobj_description(r.oid, 'pg_authid') as comment,
|
|
65
|
+
(r.oid = 10 or r.rolname ~* '^pgkiln' or r.rolname in (current_user, session_user, $1)
|
|
66
|
+
or r.oid = (select nspowner from pg_namespace where nspname = 'meta')) as pgkiln,
|
|
67
|
+
array(select 'application ' || a.alias || case when a.db_role = r.rolname then ' (parsing schema)' else ' (REST API)' end
|
|
68
|
+
from meta.app a where r.rolname in (a.db_role, a.api_role) order by a.alias)
|
|
69
|
+
|| array(select 'workspace ' || w.name from meta.workspace_role x join meta.workspace w on w.id = x.workspace_id
|
|
70
|
+
where x.role_name = r.rolname order by w.name) as used_by
|
|
71
|
+
from pg_roles r left join own o on o.role = r.oid
|
|
72
|
+
where r.rolname !~ '^pg_'`;
|
|
73
|
+
|
|
74
|
+
const listRoles = async () => (await owner.query<RoleRow>(`${ROLES_SQL} order by lower(r.rolname)`, [runtimeUser])).rows;
|
|
75
|
+
const oneRole = async (name: string) => (await owner.one<RoleRow>(`${ROLES_SQL} and r.rolname = $2`, [runtimeUser, name])) ?? null;
|
|
76
|
+
|
|
77
|
+
const context = async (): Promise<RoleContext> => (await owner.one<RoleContext>('select current_database() as database, current_user as creator'))!;
|
|
78
|
+
|
|
79
|
+
/** Schemas whose privileges the form changes: not the system's and not pgkiln's own. */
|
|
80
|
+
const grantSchemas = async () =>
|
|
81
|
+
(await owner.query<{ n: string }>(`select nspname as n from pg_namespace
|
|
82
|
+
where nspname !~ '^pg_' and nspname !~* '^pgkiln' and nspname not in ('information_schema', 'meta', 'api') order by 1`)).rows.map((r) => r.n);
|
|
83
|
+
|
|
84
|
+
/** Roles a role may be made a member of: group roles, not superusers or pgkiln's own; predefined roles last. */
|
|
85
|
+
const grantableRoles = async () =>
|
|
86
|
+
(await owner.query<{ n: string }>(
|
|
87
|
+
`select r.rolname as n from pg_roles r
|
|
88
|
+
where not r.rolcanlogin and not r.rolsuper and not r.rolbypassrls and not r.rolcreaterole and r.rolname !~* '^pgkiln'
|
|
89
|
+
and r.rolname <> all ($1) and r.oid <> (select nspowner from pg_namespace where nspname = 'meta')
|
|
90
|
+
order by r.rolname ~ '^pg_', lower(r.rolname)`, [SERVER_ROLES])).rows.map((r) => r.n);
|
|
91
|
+
|
|
92
|
+
/** What the catalog says about a role, as the form shows it. */
|
|
93
|
+
async function readSpec(role: RoleRow, schemas: string[], creator: string): Promise<RoleSpec> {
|
|
94
|
+
const path = await owner.one<{ v: string }>(
|
|
95
|
+
`select substr(x, 13) as v from pg_db_role_setting s, unnest(s.setconfig) x
|
|
96
|
+
where s.setrole = $1 and s.setdatabase = (select oid from pg_database where datname = current_database()) and x like 'search_path=%'`, [role.oid]);
|
|
97
|
+
const grants = (await owner.query<{ schema: string; priv: string }>(
|
|
98
|
+
`select n.nspname as schema, a.privilege_type as priv from pg_namespace n cross join aclexplode(n.nspacl) a where a.grantee = $1`, [role.oid])).rows;
|
|
99
|
+
const defaults = (await owner.query<{ schema: string; priv: string }>(
|
|
100
|
+
`select n.nspname as schema, a.privilege_type as priv
|
|
101
|
+
from pg_default_acl d join pg_namespace n on n.oid = d.defaclnamespace cross join aclexplode(d.defaclacl) a
|
|
102
|
+
where d.defaclrole = (select oid from pg_roles where rolname = $2) and d.defaclobjtype = 'r' and a.grantee = $1`, [role.oid, creator])).rows;
|
|
103
|
+
const kind = (schema: string): DefaultPriv => {
|
|
104
|
+
const p = defaults.filter((d) => d.schema === schema).map((d) => d.priv).sort().join(',');
|
|
105
|
+
return p === '' ? 'none' : p === 'SELECT' ? 'read' : p === 'DELETE,INSERT,SELECT,UPDATE' ? 'write' : 'custom';
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
name: role.name,
|
|
109
|
+
login: role.login,
|
|
110
|
+
password: 'keep',
|
|
111
|
+
validUntil: role.valid_until ?? '',
|
|
112
|
+
connLimit: role.conn_limit,
|
|
113
|
+
memberOf: role.member_of,
|
|
114
|
+
searchPath: path?.v ?? '',
|
|
115
|
+
comment: role.comment ?? '',
|
|
116
|
+
schemas: schemas.map((schema) => ({
|
|
117
|
+
schema,
|
|
118
|
+
usage: grants.some((g) => g.schema === schema && g.priv === 'USAGE'),
|
|
119
|
+
create: grants.some((g) => g.schema === schema && g.priv === 'CREATE'),
|
|
120
|
+
defaults: kind(schema),
|
|
121
|
+
})),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const many = (v: unknown): string[] => (v === undefined || v === null ? [] : Array.isArray(v) ? v.map(String) : [String(v)]);
|
|
126
|
+
const one = (v: unknown) => (Array.isArray(v) ? String(v[0] ?? '') : String(v ?? ''));
|
|
127
|
+
|
|
128
|
+
/** The form's values (also the review's hidden fields). */
|
|
129
|
+
function specFromBody(b: Body, isNew: boolean, original: string): RoleSpec {
|
|
130
|
+
const schemas: SchemaGrant[] = [];
|
|
131
|
+
const rows = Object.keys(b).map((k) => /^s\.(\d{1,4})\.name$/.exec(k)?.[1]).filter((i): i is string => !!i).sort((x, y) => Number(x) - Number(y));
|
|
132
|
+
for (const i of rows) {
|
|
133
|
+
schemas.push({
|
|
134
|
+
schema: one(b[`s.${i}.name`]),
|
|
135
|
+
usage: !!b[`s.${i}.usage`],
|
|
136
|
+
create: !!b[`s.${i}.create`],
|
|
137
|
+
defaults: one(b[`s.${i}.defaults`] ?? 'none') as DefaultPriv,
|
|
138
|
+
existing: one(b[`s.${i}.existing`] ?? '') as ExistingPriv,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const pw = one(b.password_mode);
|
|
142
|
+
const limit = one(b.conn_limit).trim();
|
|
143
|
+
return {
|
|
144
|
+
name: isNew ? one(b.name).trim() : original,
|
|
145
|
+
login: !!b.login,
|
|
146
|
+
password: pw === 'set' ? 'set' : pw === 'remove' && !isNew ? 'remove' : 'keep',
|
|
147
|
+
validUntil: one(b.valid_until).trim(),
|
|
148
|
+
connLimit: limit === '' ? -1 : /^-?\d{1,6}$/.test(limit) ? Number(limit) : NaN,
|
|
149
|
+
memberOf: [...new Set(many(b.member))],
|
|
150
|
+
searchPath: one(b.search_path).trim(),
|
|
151
|
+
comment: one(b.comment).trim(),
|
|
152
|
+
schemas,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const chk = (on: boolean) => (on ? raw(' checked') : '');
|
|
157
|
+
const sel = (on: boolean) => (on ? raw(' selected') : '');
|
|
158
|
+
const errorList = (errors: string[]) =>
|
|
159
|
+
errors.length ? html`<div class="alert alert-error" role="alert"><ul class="u-m0">${errors.map((e) => html`<li>${e}</li>`)}</ul></div>` : '';
|
|
160
|
+
|
|
161
|
+
const DEFAULT_LABELS: [DefaultPriv, string][] = [['none', 'None'], ['read', 'Read (SELECT)'], ['write', 'Read and write']];
|
|
162
|
+
const EXISTING_LABELS: [ExistingPriv, string][] = [['', 'No change'], ['read', 'Grant read'], ['write', 'Grant read and write'], ['revoke', 'Revoke all']];
|
|
163
|
+
|
|
164
|
+
/** The create / edit form. */
|
|
165
|
+
function roleForm(s: Session, spec: RoleSpec, o: { isNew: boolean; before: RoleSpec | null; grantable: string[]; errors: string[] }) {
|
|
166
|
+
const options = [...new Set([...o.grantable, ...(o.before?.memberOf ?? []), ...spec.memberOf])];
|
|
167
|
+
const pwModes: [string, string][] = o.isNew ? [['keep', 'No password'], ['set', 'Set a password']] : [['keep', 'Keep the password'], ['set', 'Set a new password'], ['remove', 'Remove the password']];
|
|
168
|
+
return html`${errorList(o.errors)}
|
|
169
|
+
<form method="post" action="${URL_BASE}/save" class="dbu-form">${csrf(s)}
|
|
170
|
+
<input type="hidden" name="original" value="${o.isNew ? '' : spec.name}">
|
|
171
|
+
${region('Role', html`<div class="form-grid">
|
|
172
|
+
${o.isNew ? html`<div class="field"><label class="label" for="f_name">Name</label>
|
|
173
|
+
<input id="f_name" name="name" value="${spec.name}" required maxlength="63" pattern="[A-Za-z_][A-Za-z0-9_$]*" autocomplete="off" spellcheck="false">
|
|
174
|
+
<small class="help">Letters, digits, _ and $; names starting with pg_ or pgkiln are reserved.</small></div>` : ''}
|
|
175
|
+
<div class="field"><span class="label">Sign in</span><label class="check"><input type="checkbox" name="login" value="1"${chk(spec.login)}> Can sign in (LOGIN)</label>
|
|
176
|
+
<small class="help">A role without it is a group role: others get its privileges by being members.</small></div>
|
|
177
|
+
<fieldset class="field"><legend class="label">Password</legend><div class="radio-group">
|
|
178
|
+
${pwModes.map(([v, l]) => html`<label class="check"><input type="radio" name="password_mode" value="${v}"${chk(spec.password === v)}> ${l}</label>`)}
|
|
179
|
+
</div><small class="help">You type the password on the next step, after reviewing the SQL. It is stored as a SCRAM-SHA-256 verifier and never shown.</small></fieldset>
|
|
180
|
+
<div class="field"><label class="label" for="f_valid_until">Valid until</label>
|
|
181
|
+
<input id="f_valid_until" name="valid_until" type="date" value="${spec.validUntil}">
|
|
182
|
+
<small class="help">The password stops working after this date (VALID UNTIL). Empty: it doesn't expire.</small></div>
|
|
183
|
+
<div class="field"><label class="label" for="f_conn_limit">Connection limit</label>
|
|
184
|
+
<input id="f_conn_limit" name="conn_limit" type="number" min="-1" max="100000" step="1" value="${Number.isFinite(spec.connLimit) && spec.connLimit >= 0 ? String(spec.connLimit) : ''}">
|
|
185
|
+
<small class="help">Empty: no limit.</small></div>
|
|
186
|
+
<div class="field"><label class="label" for="f_search_path">Default schema (search path)</label>
|
|
187
|
+
<input id="f_search_path" name="search_path" value="${spec.searchPath}" placeholder="sales, public" spellcheck="false" autocomplete="off">
|
|
188
|
+
<small class="help">Schemas, comma separated, for this role's sessions in this database. Empty: the server's default.</small></div>
|
|
189
|
+
<div class="field"><label class="label" for="f_comment">Comment</label>
|
|
190
|
+
<input id="f_comment" name="comment" value="${spec.comment}" maxlength="1000"></div>
|
|
191
|
+
</div>`)}
|
|
192
|
+
${region('Member of', html`<p class="muted u-mt0">The role gets the privileges of the roles it is a member of. pgkiln's own roles, superusers, login roles and the predefined roles that reach the server's files can't be granted here.</p>
|
|
193
|
+
${options.length ? html`<fieldset class="field"><legend class="sr-only">Member of</legend><div class="radio-group dbu-members">
|
|
194
|
+
${options.map((m) => html`<label class="check"><input type="checkbox" name="member" value="${m}"${chk(spec.memberOf.includes(m))}> ${m}</label>`)}
|
|
195
|
+
</div></fieldset>` : html`<p class="muted">There are no group roles to grant.</p>`}`)}
|
|
196
|
+
${region('Schemas', html`<p class="muted u-mt0"><strong>Usage</strong> lets the role use a schema's objects (as far as their own privileges allow), <strong>Create</strong> make objects in it. <strong>Future tables</strong> are default privileges on the tables (and, for writing, sequences) pgkiln's owner connection creates there from now on; <strong>Existing tables</strong> grants or revokes once on the tables there now. pgkiln's own schemas aren't listed.</p>
|
|
197
|
+
${spec.schemas.length ? html`<div class="table-wrap"><table class="report dbu-schemas"><thead><tr><th>Schema</th><th class="te-c">Usage</th><th class="te-c">Create</th><th>Future tables</th><th>Existing tables</th></tr></thead>
|
|
198
|
+
<tbody>${spec.schemas.map((x, i) => html`<tr><td><code>${x.schema}</code><input type="hidden" name="s.${i}.name" value="${x.schema}"></td>
|
|
199
|
+
<td class="te-c"><input type="checkbox" name="s.${i}.usage" value="1" aria-label="Usage on ${x.schema}"${chk(x.usage)}></td>
|
|
200
|
+
<td class="te-c"><input type="checkbox" name="s.${i}.create" value="1" aria-label="Create in ${x.schema}"${chk(x.create)}></td>
|
|
201
|
+
<td><select name="s.${i}.defaults" aria-label="Future tables in ${x.schema}">${x.defaults === 'custom' ? html`<option value="custom" selected>As they are (custom)</option>` : ''}${DEFAULT_LABELS.map(([v, l]) => html`<option value="${v}"${sel(x.defaults === v)}>${l}</option>`)}</select></td>
|
|
202
|
+
<td><select name="s.${i}.existing" aria-label="Existing tables in ${x.schema}">${EXISTING_LABELS.map(([v, l]) => html`<option value="${v}"${sel((x.existing ?? '') === v)}>${l}</option>`)}</select></td></tr>`)}
|
|
203
|
+
</tbody></table></div>` : html`<p class="muted">No schemas.</p>`}`)}
|
|
204
|
+
<div class="buttons"><button class="btn btn-hot" name="mode" value="review">${icon('eye')} Review SQL</button><a class="btn" href="${URL_BASE}">Cancel</a></div>
|
|
205
|
+
</form>`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The form's values as hidden fields (the review step carries them to Apply). */
|
|
209
|
+
const hiddenSpec = (spec: RoleSpec, isNew: boolean) => html`
|
|
210
|
+
<input type="hidden" name="original" value="${isNew ? '' : spec.name}">
|
|
211
|
+
${isNew ? html`<input type="hidden" name="name" value="${spec.name}">` : ''}
|
|
212
|
+
${spec.login ? html`<input type="hidden" name="login" value="1">` : ''}
|
|
213
|
+
<input type="hidden" name="password_mode" value="${spec.password}">
|
|
214
|
+
<input type="hidden" name="valid_until" value="${spec.validUntil}">
|
|
215
|
+
<input type="hidden" name="conn_limit" value="${spec.connLimit >= 0 ? String(spec.connLimit) : ''}">
|
|
216
|
+
${spec.memberOf.map((m) => html`<input type="hidden" name="member" value="${m}">`)}
|
|
217
|
+
<input type="hidden" name="search_path" value="${spec.searchPath}">
|
|
218
|
+
<input type="hidden" name="comment" value="${spec.comment}">
|
|
219
|
+
${spec.schemas.map((x, i) => html`<input type="hidden" name="s.${i}.name" value="${x.schema}">${x.usage ? html`<input type="hidden" name="s.${i}.usage" value="1">` : ''}${x.create ? html`<input type="hidden" name="s.${i}.create" value="1">` : ''}<input type="hidden" name="s.${i}.defaults" value="${x.defaults}"><input type="hidden" name="s.${i}.existing" value="${x.existing ?? ''}">`)}`;
|
|
220
|
+
|
|
221
|
+
/** The review step: the SQL (the password masked), the password fields when one is set, Apply. */
|
|
222
|
+
function reviewForm(s: Session, spec: RoleSpec, stmts: string[], o: { isNew: boolean; errors: string[]; output?: Raw | '' }) {
|
|
223
|
+
return html`${errorList(o.errors)}${o.output ?? ''}
|
|
224
|
+
${region('SQL', stmts.length
|
|
225
|
+
? html`<p class="muted u-mt0">These statements run in one transaction when you apply them: all of them, or none.${spec.password === 'set' ? ' The password shows as \'********\': pgkiln sends PostgreSQL only its SCRAM-SHA-256 verifier.' : ''}</p>
|
|
226
|
+
<pre class="source dbu-sql" aria-label="Generated SQL">${maskSql(stmts)}</pre>`
|
|
227
|
+
: html`<p class="muted u-mt0">Nothing to change: the role is as the form says.</p>`)}
|
|
228
|
+
<form method="post" action="${URL_BASE}/save" class="dbu-form">${csrf(s)}${hiddenSpec(spec, o.isNew)}
|
|
229
|
+
<input type="hidden" name="reviewed" value="${reviewDigest(stmts)}">
|
|
230
|
+
${spec.password === 'set' ? region('Password', html`<div class="form-grid">
|
|
231
|
+
<div class="field"><label class="label" for="f_password">Password</label><input id="f_password" name="password" type="password" autocomplete="new-password" minlength="8" maxlength="1024" required></div>
|
|
232
|
+
<div class="field"><label class="label" for="f_confirm">Confirm password</label><input id="f_confirm" name="confirm" type="password" autocomplete="new-password" minlength="8" maxlength="1024" required></div>
|
|
233
|
+
</div><small class="help">At least 8 characters. It is hashed by pgkiln before it reaches the database, and it is not kept.</small>`) : ''}
|
|
234
|
+
<div class="buttons">${stmts.length ? html`<button class="btn btn-hot" name="mode" value="apply">${icon('check')} Apply</button>` : ''}
|
|
235
|
+
<button class="btn" name="mode" value="edit" formnovalidate>Back to the form</button></div>
|
|
236
|
+
</form>`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** A role's attributes as tags. */
|
|
240
|
+
const attrTags = (r: RoleRow) => html`${r.pgkiln ? html`<span class="tag tag-info">pgkiln</span> ` : ''}${r.used_by.length ? html`<span class="tag" title="${r.used_by.join(', ')}">in use</span> ` : ''}${
|
|
241
|
+
r.superuser ? html`<span class="tag tag-error">superuser</span> ` : ''}${r.bypassrls ? html`<span class="tag tag-warning">bypass RLS</span> ` : ''}${
|
|
242
|
+
r.createrole ? html`<span class="tag tag-warning">create role</span> ` : ''}${r.createdb ? html`<span class="tag">create DB</span> ` : ''}${r.replication ? html`<span class="tag">replication</span>` : ''}`;
|
|
243
|
+
|
|
244
|
+
const expiry = (r: RoleRow) => r.valid_until === null ? html`<span class="muted">never</span>` : html`${r.valid_until}${
|
|
245
|
+
r.days_left !== null && r.days_left < 0 ? html` <span class="tag tag-error">expired</span>` : r.days_left !== null && r.days_left <= 30 ? html` <span class="tag tag-warning">in ${String(r.days_left)} day${r.days_left === 1 ? '' : 's'}</span>` : ''}`;
|
|
246
|
+
|
|
247
|
+
const FILTERS: [string, string, (r: RoleRow) => boolean][] = [
|
|
248
|
+
['', 'All roles', () => true],
|
|
249
|
+
['login', 'Can sign in', (r) => r.login],
|
|
250
|
+
['expiring', 'Password expires within 30 days', (r) => r.login && r.days_left !== null && r.days_left >= 0 && r.days_left <= 30],
|
|
251
|
+
['bypassrls', 'Bypass row level security', (r) => r.bypassrls],
|
|
252
|
+
['owners', 'No sign-in, but own objects', (r) => !r.login && r.owned > 0],
|
|
253
|
+
['pgkiln', 'pgkiln\'s own', (r) => r.pgkiln],
|
|
254
|
+
];
|
|
255
|
+
|
|
256
|
+
async function listPage(req: Req, reply: FastifyReply, s: Session) {
|
|
257
|
+
const roles = await listRoles();
|
|
258
|
+
const f = FILTERS.find(([k]) => k === (req.query.f ?? '')) ?? FILTERS[0];
|
|
259
|
+
const q = String(req.query.q ?? '').trim().toLowerCase().slice(0, 100);
|
|
260
|
+
const shown = roles.filter((r) => f[2](r) && (!q || r.name.toLowerCase().includes(q)));
|
|
261
|
+
const main = html`<div class="ide-head-title u-mb1"><div><h1>${TITLE}</h1><span class="muted small">The PostgreSQL roles of this server (pg_roles), without the predefined pg_ roles.</span></div>
|
|
262
|
+
<div class="buttons ide-head-actions"><a class="btn btn-hot" href="${URL_BASE}/new">${icon('plus')} Create database user</a></div></div>
|
|
263
|
+
<nav class="stat-grid u-mb1 dbu-stats" aria-label="Filters">${FILTERS.map(([k, label, fn]) => html`<a class="stat" href="${URL_BASE}${k ? `?f=${k}` : ''}"${k === f[0] ? raw(' aria-current="page"') : ''}><b>${String(roles.filter(fn).length)}</b><span>${label}</span></a>`)}</nav>
|
|
264
|
+
${region(f[1], html`<form method="get" action="${URL_BASE}" class="dbu-search u-mb1" role="search">${f[0] ? html`<input type="hidden" name="f" value="${f[0]}">` : ''}
|
|
265
|
+
<label class="sr-only" for="dbu-q">Search roles</label><input id="dbu-q" name="q" type="search" value="${q}" placeholder="Search roles" autocomplete="off"> <button class="btn btn-sm">${icon('search')} Search</button></form>
|
|
266
|
+
<div class="table-wrap"><table class="report report-reflow dbu-list"><thead><tr><th>Role</th><th>Sign in</th><th>Attributes</th><th class="num">Connections</th><th>Valid until</th><th>Member of</th><th>Owns</th></tr></thead>
|
|
267
|
+
<tbody>${shown.length ? shown.map((r) => html`<tr>
|
|
268
|
+
<td data-label="Role" class="u-break"><a href="${roleUrl(r.name)}"><strong>${r.name}</strong></a>${r.comment ? html`<br><span class="muted small">${r.comment}</span>` : ''}</td>
|
|
269
|
+
<td data-label="Sign in">${r.login ? 'Yes' : html`<span class="muted">No</span>`}</td>
|
|
270
|
+
<td data-label="Attributes">${attrTags(r)}</td>
|
|
271
|
+
<td data-label="Connections" class="num">${r.conn_limit < 0 ? html`<span class="muted">no limit</span>` : String(r.conn_limit)}</td>
|
|
272
|
+
<td data-label="Valid until">${expiry(r)}</td>
|
|
273
|
+
<td data-label="Member of" class="u-break">${r.member_of.join(', ')}</td>
|
|
274
|
+
<td data-label="Owns" class="u-break">${r.schemas.length ? html`schemas ${r.schemas.join(', ')}; ` : ''}${r.owned ? `${r.owned} object${r.owned === 1 ? '' : 's'}` : r.schemas.length ? '' : html`<span class="muted">nothing</span>`}${r.owned_elsewhere ? html` <span class="muted">(+${String(r.owned_elsewhere)} elsewhere)</span>` : ''}</td></tr>`)
|
|
275
|
+
: html`<tr><td colspan="7" class="empty">No roles.</td></tr>`}</tbody></table></div>
|
|
276
|
+
<p class="muted small">PostgreSQL doesn't record when a role was created. <span class="tag tag-info">pgkiln</span> roles are pgkiln's own connections and roles: they are shown, not changed here. Roles <span class="tag">in use</span> by an application or an isolated workspace can be changed but not dropped.</p>`)}`;
|
|
277
|
+
return send(reply, s, shell(s, TITLE, crumbs(), main, 'users'));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const crumbs = (...more: [string, string?][]): [string, string?][] => [['Workspace utilities', `${BASE}/utilities`], [TITLE, more.length ? URL_BASE : undefined], ...more];
|
|
281
|
+
|
|
282
|
+
/** A role's details: owned objects, members, users of it. */
|
|
283
|
+
async function details(r: RoleRow) {
|
|
284
|
+
const owned = (await owner.query<{ what: string }>(
|
|
285
|
+
`select pg_describe_object(d.classid, d.objid, d.objsubid) as what from pg_shdepend d
|
|
286
|
+
where d.refobjid = $1 and d.refclassid = 'pg_authid'::regclass and d.deptype = 'o'
|
|
287
|
+
and d.dbid = (select oid from pg_database where datname = current_database()) order by 1 limit 50`, [r.oid])).rows.map((x) => x.what);
|
|
288
|
+
const members = (await owner.query<{ n: string }>(
|
|
289
|
+
`select distinct m.member::regrole::text as n from pg_auth_members m where m.roleid = $1 order by 1`, [r.oid])).rows.map((x) => x.n);
|
|
290
|
+
return { owned, members };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function detailRegion(r: RoleRow, d: { owned: string[]; members: string[] }) {
|
|
294
|
+
return region('Details', html`<dl class="dbu-facts">
|
|
295
|
+
<dt>Attributes</dt><dd>${r.login ? 'Can sign in' : 'No sign-in (a group role)'} ${attrTags(r)}</dd>
|
|
296
|
+
<dt>Valid until</dt><dd>${expiry(r)}</dd>
|
|
297
|
+
<dt>Connection limit</dt><dd>${r.conn_limit < 0 ? 'no limit' : String(r.conn_limit)}</dd>
|
|
298
|
+
<dt>Member of</dt><dd class="u-break">${r.member_of.join(', ') || html`<span class="muted">none</span>`}</dd>
|
|
299
|
+
<dt>Members</dt><dd class="u-break">${d.members.join(', ') || html`<span class="muted">none</span>`}</dd>
|
|
300
|
+
${r.used_by.length ? html`<dt>Used by</dt><dd class="u-break">${r.used_by.join(', ')}</dd>` : ''}
|
|
301
|
+
<dt>Owns</dt><dd class="u-break">${d.owned.length ? html`${r.owned > d.owned.length ? `${r.owned} objects, the first ${d.owned.length}: ` : ''}${d.owned.join(', ')}` : html`<span class="muted">nothing in this database</span>`}${r.owned_elsewhere ? html` <span class="tag tag-warning">${String(r.owned_elsewhere)} in other databases or shared</span>` : ''}</dd>
|
|
302
|
+
</dl>`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Can this role be changed here? A message when not. */
|
|
306
|
+
const protectedMessage = (r: RoleRow) => r.pgkiln
|
|
307
|
+
? `${r.name} is one of pgkiln's own roles (its owner or runtime connection, a pgkiln_ role or the bootstrap superuser): pgkiln needs it as it is, so it isn't changed or dropped here.`
|
|
308
|
+
: '';
|
|
309
|
+
|
|
310
|
+
async function rolePage(reply: FastifyReply, s: Session, name: string | null, o: { spec?: RoleSpec; errors?: string[] } = {}) {
|
|
311
|
+
const [ctx, schemas, grantable] = await Promise.all([context(), grantSchemas(), grantableRoles()]);
|
|
312
|
+
if (name === null) {
|
|
313
|
+
const spec = o.spec ?? { ...emptyRole(), login: true, schemas: schemas.map((schema) => ({ schema, usage: false, create: false, defaults: 'none' as DefaultPriv })) };
|
|
314
|
+
const main = html`<h1 class="u-mb1">Create database user</h1>${roleForm(s, spec, { isNew: true, before: null, grantable, errors: o.errors ?? [] })}`;
|
|
315
|
+
return send(reply, s, shell(s, 'Create database user', crumbs(['Create']), main, 'users'));
|
|
316
|
+
}
|
|
317
|
+
const r = await oneRole(name);
|
|
318
|
+
if (!r) return reply.code(404).send('Not found');
|
|
319
|
+
const d = await details(r);
|
|
320
|
+
const msg = protectedMessage(r);
|
|
321
|
+
const before = msg ? null : await readSpec(r, schemas, ctx.creator);
|
|
322
|
+
const main = html`<div class="ide-head-title u-mb1"><div><h1 class="u-break">Role ${r.name}</h1>${r.comment ? html`<span class="muted small">${r.comment}</span>` : ''}</div>
|
|
323
|
+
${msg || r.used_by.length ? '' : html`<div class="buttons ide-head-actions"><a class="btn btn-danger" href="${roleUrl(r.name)}/drop">${icon('trash')} Drop role…</a></div>`}</div>
|
|
324
|
+
${msg ? html`<div class="alert alert-info" role="status">${msg}</div>` : ''}
|
|
325
|
+
${!msg && r.used_by.length ? html`<div class="alert alert-info" role="status">${r.used_by.join(', ')} use${r.used_by.length === 1 ? 's' : ''} this role: it can be changed, not dropped.</div>` : ''}
|
|
326
|
+
${detailRegion(r, d)}
|
|
327
|
+
${before ? roleForm(s, o.spec ?? before, { isNew: false, before, grantable, errors: o.errors ?? [] }) : ''}`;
|
|
328
|
+
return send(reply, s, shell(s, `Role ${r.name}`, crumbs([r.name]), main, 'users'));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function save(req: Req, reply: FastifyReply, s: Session) {
|
|
332
|
+
const b = req.body ?? {};
|
|
333
|
+
const original = one(b.original);
|
|
334
|
+
const isNew = !original;
|
|
335
|
+
const [ctx, schemas, grantable] = await Promise.all([context(), grantSchemas(), grantableRoles()]);
|
|
336
|
+
const r = isNew ? null : await oneRole(original);
|
|
337
|
+
if (!isNew && !r) return reply.code(404).send('Not found');
|
|
338
|
+
if (r && r.pgkiln) {
|
|
339
|
+
const main = html`<h1 class="u-mb1">Role ${r.name}</h1><div class="alert alert-error" role="alert">${protectedMessage(r)}</div>`;
|
|
340
|
+
return send(reply.code(403), s, shell(s, `Role ${r.name}`, crumbs([r.name]), main, 'users'));
|
|
341
|
+
}
|
|
342
|
+
const before = r ? await readSpec(r, schemas, ctx.creator) : null;
|
|
343
|
+
const spec = specFromBody(b, isNew, original);
|
|
344
|
+
const mode = one(b.mode);
|
|
345
|
+
if (mode === 'edit') return rolePage(reply, s, isNew ? null : original, { spec });
|
|
346
|
+
const errors = checkRole(spec, { isNew, grantable, schemas, before });
|
|
347
|
+
if (isNew && !errors.length && (await owner.one('select 1 from pg_roles where lower(rolname) = lower($1)', [spec.name]))) errors.push(`A role named ${spec.name} exists already.`);
|
|
348
|
+
if (errors.length) return rolePage(reply, s, isNew ? null : original, { spec, errors });
|
|
349
|
+
const stmts = roleSql(before, spec, ctx);
|
|
350
|
+
const title = isNew ? `Create database user ${spec.name}` : `Change role ${spec.name}`;
|
|
351
|
+
const page = (errs: string[], output: Raw | '' = '') => {
|
|
352
|
+
const main = html`<h1 class="u-mb1 u-break">${title}</h1>${reviewForm(s, spec, stmts, { isNew, errors: errs, output })}`;
|
|
353
|
+
const trail: [string, string?][] = isNew ? [['Create', `${URL_BASE}/new`], ['Review']] : [[spec.name, roleUrl(spec.name)], ['Review']];
|
|
354
|
+
return send(reply.code(errs.length || output ? 422 : 200), s, shell(s, title, crumbs(...trail), main, 'users'));
|
|
355
|
+
};
|
|
356
|
+
if (mode !== 'apply') return page([]);
|
|
357
|
+
// Apply runs exactly what the review showed: a change in the form or the catalog since then shows the review again
|
|
358
|
+
if (one(b.reviewed) !== reviewDigest(stmts)) return page(['The statements changed since you reviewed them (the role or the form changed): review them again.']);
|
|
359
|
+
if (!stmts.length) return page(['Nothing to change.']);
|
|
360
|
+
const password = one(b.password);
|
|
361
|
+
if (spec.password === 'set') {
|
|
362
|
+
const bad = checkPassword(password, one(b.confirm));
|
|
363
|
+
if (bad.length) return page(bad);
|
|
364
|
+
}
|
|
365
|
+
const run = withVerifier(stmts, spec.password === 'set' ? scramVerifier(password) : null);
|
|
366
|
+
let failed = '';
|
|
367
|
+
let at = -1;
|
|
368
|
+
try {
|
|
369
|
+
await owner.tx(async (c) => {
|
|
370
|
+
await c.query(`set local lock_timeout = '10s'`);
|
|
371
|
+
for (let i = 0; i < run.length; i++) {
|
|
372
|
+
at = i;
|
|
373
|
+
await c.query(run[i]);
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
} catch (e) {
|
|
377
|
+
// PostgreSQL's message names the role or object, never the statement's literals
|
|
378
|
+
failed = (e as Error).message;
|
|
379
|
+
}
|
|
380
|
+
await logActivity({ username: s.username, event: 'db_role', ip: clientIp(req), detail: `${failed ? 'failed: ' : ''}${maskSql(stmts)}`.slice(0, 4000) });
|
|
381
|
+
if (failed) {
|
|
382
|
+
return page([], html`<div class="alert alert-error" role="alert"><p><strong>Nothing was changed</strong>: the statements ran in one transaction, which was rolled back.</p><p>${failed}</p>${at >= 0 ? html`<pre class="source">${maskSql([stmts[at]])}</pre>` : ''}</div>`);
|
|
383
|
+
}
|
|
384
|
+
flash(s, `${isNew ? 'Created' : 'Changed'} the role ${spec.name}: ${stmts.length} statement${stmts.length === 1 ? '' : 's'}.`);
|
|
385
|
+
return back(reply, s, isNew ? URL_BASE : roleUrl(spec.name));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Roles another role's objects can be given to. */
|
|
389
|
+
const reassignTargets = async (name: string) =>
|
|
390
|
+
(await owner.query<{ n: string }>(`select rolname as n from pg_roles where rolname !~ '^pg_' and rolname <> $1 order by rolname <> current_user, lower(rolname)`, [name])).rows.map((x) => x.n);
|
|
391
|
+
|
|
392
|
+
async function dropPage(req: Req, reply: FastifyReply, s: Session, name: string, o: { to?: string; errors?: string[]; output?: Raw | '' } = {}) {
|
|
393
|
+
const r = await oneRole(name);
|
|
394
|
+
if (!r) return reply.code(404).send('Not found');
|
|
395
|
+
const refusal = r.pgkiln ? protectedMessage(r) : r.used_by.length ? `${r.used_by.join(', ')} use${r.used_by.length === 1 ? 's' : ''} ${r.name}: it isn't dropped while they do.` : '';
|
|
396
|
+
const d = await details(r);
|
|
397
|
+
const targets = await reassignTargets(name);
|
|
398
|
+
const to = o.to !== undefined && (o.to === '' || targets.includes(o.to)) ? o.to : r.owned ? targets[0] ?? '' : '';
|
|
399
|
+
const stmts = dropSql(r.name, to || null);
|
|
400
|
+
const main = html`<h1 class="u-mb1 u-break">Drop role ${r.name}</h1>
|
|
401
|
+
${refusal ? html`<div class="alert alert-error" role="alert">${refusal}</div>` : ''}${errorList(o.errors ?? [])}${o.output ?? ''}
|
|
402
|
+
${detailRegion(r, d)}
|
|
403
|
+
${refusal ? '' : html`
|
|
404
|
+
${r.owned || r.owned_elsewhere ? html`<div class="alert alert-error" role="alert"><p><strong>${r.name} owns ${String(r.owned)} object${r.owned === 1 ? '' : 's'} in this database${r.owned_elsewhere ? html` and ${String(r.owned_elsewhere)} in other databases or shared (databases, tablespaces)` : ''}.</strong>
|
|
405
|
+
Reassign them to another role, or drop them with the role.${r.owned_elsewhere ? ' Objects in other databases must be reassigned or dropped there first, or DROP ROLE fails.' : ''}</p></div>` : ''}
|
|
406
|
+
${region('Owned objects', html`<form method="get" action="${roleUrl(r.name)}/drop" class="form-grid">
|
|
407
|
+
<div class="field"><label class="label" for="f_to">Objects it owns</label><select id="f_to" name="to">
|
|
408
|
+
${targets.map((t) => html`<option value="${t}"${sel(t === to)}>Reassign to ${t}</option>`)}
|
|
409
|
+
<option value=""${sel(to === '')}>${r.owned ? 'Drop them with the role (DROP OWNED)' : 'It owns nothing here: just revoke its privileges'}</option></select>
|
|
410
|
+
<small class="help">DROP OWNED also revokes the privileges granted to the role in this database.</small></div>
|
|
411
|
+
<div class="buttons"><button class="btn">${icon('eye')} Review SQL</button></div></form>`)}
|
|
412
|
+
${region('SQL', html`<pre class="source dbu-sql" aria-label="Generated SQL">${maskSql(stmts)}</pre>
|
|
413
|
+
<form method="post" action="${URL_BASE}/drop" class="dbu-form">${csrf(s)}
|
|
414
|
+
<input type="hidden" name="name" value="${r.name}"><input type="hidden" name="to" value="${to}"><input type="hidden" name="reviewed" value="${reviewDigest(stmts)}">
|
|
415
|
+
${!to && r.owned ? html`<label class="check u-mb1"><input type="checkbox" name="drop_objects" value="1" required> Drop the ${String(r.owned)} object${r.owned === 1 ? '' : 's'} ${r.name} owns, with their data</label>` : ''}
|
|
416
|
+
<div class="buttons"><button class="btn btn-danger" data-confirm="Drop the role ${r.name}?">${icon('trash')} Drop role</button><a class="btn" href="${roleUrl(r.name)}">Cancel</a></div></form>`)}`}`;
|
|
417
|
+
return send(reply.code(refusal ? 403 : o.errors?.length || o.output ? 422 : 200), s, shell(s, `Drop role ${r.name}`, crumbs([r.name, roleUrl(r.name)], ['Drop']), main, 'users'));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function drop(req: Req, reply: FastifyReply, s: Session) {
|
|
421
|
+
const b = req.body ?? {};
|
|
422
|
+
const name = one(b.name);
|
|
423
|
+
const to = one(b.to);
|
|
424
|
+
const r = await oneRole(name);
|
|
425
|
+
if (!r) return reply.code(404).send('Not found');
|
|
426
|
+
if (r.pgkiln || r.used_by.length) return dropPage(req, reply, s, name, { to });
|
|
427
|
+
const targets = await reassignTargets(name);
|
|
428
|
+
if (to && !targets.includes(to)) return dropPage(req, reply, s, name, { errors: ['Choose a role to reassign the objects to.'] });
|
|
429
|
+
const stmts = dropSql(r.name, to || null);
|
|
430
|
+
if (one(b.reviewed) !== reviewDigest(stmts)) return dropPage(req, reply, s, name, { to, errors: ['The statements changed since you reviewed them: review them again.'] });
|
|
431
|
+
if (!to && r.owned && !b.drop_objects) return dropPage(req, reply, s, name, { to, errors: [`Tick the box to drop the objects ${r.name} owns, or reassign them.`] });
|
|
432
|
+
let failed = '';
|
|
433
|
+
try {
|
|
434
|
+
await owner.tx(async (c) => {
|
|
435
|
+
await c.query(`set local lock_timeout = '10s'`);
|
|
436
|
+
for (const x of stmts) await c.query(x);
|
|
437
|
+
});
|
|
438
|
+
} catch (e) {
|
|
439
|
+
failed = (e as Error).message;
|
|
440
|
+
}
|
|
441
|
+
await logActivity({ username: s.username, event: 'db_role', ip: clientIp(req), detail: `${failed ? 'failed: ' : ''}${maskSql(stmts)}`.slice(0, 4000) });
|
|
442
|
+
if (failed) return dropPage(req, reply, s, name, { to, output: html`<div class="alert alert-error" role="alert"><p><strong>Nothing was changed</strong>: the statements ran in one transaction, which was rolled back.</p><p>${failed}</p></div>` });
|
|
443
|
+
flash(s, `Dropped the role ${r.name}.`);
|
|
444
|
+
return back(reply, s, URL_BASE);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export async function dbUserRoutes(app: FastifyInstance) {
|
|
448
|
+
app.get(URL_BASE, async (req: Req, reply) => {
|
|
449
|
+
const s = await administrator(req, reply, TITLE);
|
|
450
|
+
if (s) return listPage(req, reply, s);
|
|
451
|
+
});
|
|
452
|
+
app.get(`${URL_BASE}/new`, async (req: Req, reply) => {
|
|
453
|
+
const s = await administrator(req, reply, TITLE);
|
|
454
|
+
if (s) return rolePage(reply, s, null);
|
|
455
|
+
});
|
|
456
|
+
app.get(`${URL_BASE}/role/:name`, async (req: Req, reply) => {
|
|
457
|
+
const s = await administrator(req, reply, TITLE);
|
|
458
|
+
if (s) return rolePage(reply, s, req.params.name);
|
|
459
|
+
});
|
|
460
|
+
app.post(`${URL_BASE}/save`, async (req: Req, reply) => {
|
|
461
|
+
const s = await administrator(req, reply, TITLE);
|
|
462
|
+
if (s) return save(req, reply, s);
|
|
463
|
+
});
|
|
464
|
+
app.get(`${URL_BASE}/role/:name/drop`, async (req: Req, reply) => {
|
|
465
|
+
const s = await administrator(req, reply, TITLE);
|
|
466
|
+
if (s) return dropPage(req, reply, s, req.params.name, { to: req.query.to });
|
|
467
|
+
});
|
|
468
|
+
app.post(`${URL_BASE}/drop`, async (req: Req, reply) => {
|
|
469
|
+
const s = await administrator(req, reply, TITLE);
|
|
470
|
+
if (s) return drop(req, reply, s);
|
|
471
|
+
});
|
|
472
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
|
+
import { owner } from '../db.ts';
|
|
3
|
+
import { html, type Raw } from '../html.ts';
|
|
4
|
+
import { icon } from '../icons.ts';
|
|
5
|
+
import type { Session } from '../session.ts';
|
|
6
|
+
import { back, BASE, csrf, designerUrl, developer, flash, send, shell, type Req } from './ui.ts';
|
|
7
|
+
import { SQL_TOOLS, TOOL_GROUPS } from './sql-tools.ts';
|
|
8
|
+
import { SYSTEM_SCHEMAS } from './object-browser.ts';
|
|
9
|
+
import { currentWorkspace, inWorkspaceSql, workshopDb } from './workspaces.ts';
|
|
10
|
+
import { isAdmin } from './locks.ts';
|
|
11
|
+
|
|
12
|
+
// (sprint 96, Database Actions DA-15) The SQL Workshop's Launchpad and the builder's search.
|
|
13
|
+
// - The home page starts with Pinned (per developer and workspace, meta.builder_pin) and Recently visited (kept
|
|
14
|
+
// in the browser by public/palette.js), then the tools in tabs: Development, Administration, Monitoring, each
|
|
15
|
+
// with a line of description and a pin.
|
|
16
|
+
// - POST /builder/pins pins or unpins a builder page (a tool, an object, a saved worksheet).
|
|
17
|
+
// - GET /builder/palette?q= finds tools, applications, pages and database objects (tables, views, functions) by
|
|
18
|
+
// name: JSON for the Ctrl+K search, or a page of results without JavaScript (the header's search field).
|
|
19
|
+
|
|
20
|
+
const MAX_PINS = 24;
|
|
21
|
+
const HREF_RE = /^\/builder\/[A-Za-z0-9/_\-.~%]*(\?[A-Za-z0-9_\-.~%=&+]*)?$/;
|
|
22
|
+
|
|
23
|
+
export interface Pin { href: string; label: string; icon: string }
|
|
24
|
+
|
|
25
|
+
export async function pinsOf(s: Session): Promise<Pin[]> {
|
|
26
|
+
return (await owner.query<Pin>(`select href, label, icon from meta.builder_pin where username = $1 and workspace_id = $2 order by at`, [s.username, currentWorkspace(s)])).rows;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A pin toggle (a small form): pinned → unpin, else pin. */
|
|
30
|
+
export function pinButton(s: Session, p: Pin, pinned: boolean, backTo: string): Raw {
|
|
31
|
+
return html`<form method="post" action="${BASE}/pins" class="pin-form">${csrf(s)}
|
|
32
|
+
<input type="hidden" name="href" value="${p.href}"><input type="hidden" name="label" value="${p.label}"><input type="hidden" name="icon" value="${p.icon}">
|
|
33
|
+
<input type="hidden" name="pin" value="${pinned ? '0' : '1'}"><input type="hidden" name="back" value="${backTo}">
|
|
34
|
+
<button class="pin-btn${pinned ? ' is-pinned' : ''}" title="${pinned ? 'Unpin' : 'Pin to the SQL Workshop home'}" aria-pressed="${pinned ? 'true' : 'false'}">${icon('pin')}<span class="sr-only">${pinned ? 'Unpin' : 'Pin'} ${p.label}</span></button></form>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The Launchpad part of the SQL Workshop home. */
|
|
38
|
+
export async function launchpad(s: Session, admin: boolean): Promise<Raw> {
|
|
39
|
+
const pins = await pinsOf(s);
|
|
40
|
+
const pinned = new Set(pins.map((p) => p.href));
|
|
41
|
+
const tools = SQL_TOOLS.filter((t) => admin || !t.admin);
|
|
42
|
+
const card = (t: (typeof SQL_TOOLS)[number]) => html`<li class="lp-tool">
|
|
43
|
+
<a href="${t.href}">${icon(t.icon)}<span><strong>${t.label}</strong><small class="muted">${t.description}</small></span></a>
|
|
44
|
+
${pinButton(s, { href: t.href, label: t.label, icon: t.icon }, pinned.has(t.href), `${BASE}/sql`)}</li>`;
|
|
45
|
+
return html`<section class="lp-pins" aria-labelledby="lp-pins-title">
|
|
46
|
+
<h2 id="lp-pins-title">Pinned & recently visited</h2>
|
|
47
|
+
${pins.length ? html`<ul class="lp-chips">${pins.map((p) => html`<li><a href="${p.href}">${icon(p.icon)}<span>${p.label}</span></a>${pinButton(s, p, true, `${BASE}/sql`)}</li>`)}</ul>`
|
|
48
|
+
: html`<p class="muted small">Pin a tool, an object or a saved worksheet with ${icon('pin')} to keep it here.</p>`}
|
|
49
|
+
<ul class="lp-chips lp-recent" data-lp-recent aria-label="Recently visited" hidden></ul>
|
|
50
|
+
</section>
|
|
51
|
+
<div class="lp-groups">
|
|
52
|
+
<div class="lp-tabs" role="tablist" aria-label="Tools" hidden>${TOOL_GROUPS.map(([g, label], i) => html`<button type="button" role="tab" aria-selected="${i === 0 ? 'true' : 'false'}" aria-controls="lp-${g}" data-lp-tab="${g}">${label}</button>`)}</div>
|
|
53
|
+
${TOOL_GROUPS.map(([g, label]) => html`<section class="lp-group" id="lp-${g}" data-lp-group="${g}" aria-label="${label}"><h2>${label}</h2><ul class="lp-list">${tools.filter((t) => t.group === g).map(card)}</ul></section>`)}
|
|
54
|
+
</div>`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface Result { kind: string; label: string; detail: string; href: string; icon: string }
|
|
58
|
+
|
|
59
|
+
/** What the builder's search finds for `q`. */
|
|
60
|
+
async function search(s: Session, q: string, admin: boolean): Promise<Result[]> {
|
|
61
|
+
const needle = q.toLowerCase();
|
|
62
|
+
const like = `%${q.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
|
63
|
+
const out: Result[] = SQL_TOOLS.filter((t) => (admin || !t.admin) && `${t.label} ${t.description}`.toLowerCase().includes(needle))
|
|
64
|
+
.map((t) => ({ kind: 'Tool', label: t.label, detail: t.description, href: t.href, icon: t.icon }));
|
|
65
|
+
const ws = currentWorkspace(s);
|
|
66
|
+
const apps = (await owner.query<{ id: number; name: string; alias: string }>(
|
|
67
|
+
`select a.id, a.name, a.alias from meta.app a where ${inWorkspaceSql('a', 1)} and (a.name ilike $2 or a.alias ilike $2) order by a.name limit 8`, [ws, like])).rows;
|
|
68
|
+
out.push(...apps.map((a) => ({ kind: 'Application', label: a.name, detail: a.alias, href: `${BASE}/apps/${a.id}`, icon: 'grid' })));
|
|
69
|
+
const pages = (await owner.query<{ app_id: number; page_no: number; name: string; app: string }>(
|
|
70
|
+
`select p.app_id, p.page_no, p.name, a.name as app from meta.page p join meta.app a on a.id = p.app_id
|
|
71
|
+
where ${inWorkspaceSql('a', 1)} and (p.name ilike $2 or p.page_no::text = $3) order by a.name, p.page_no limit 12`, [ws, like, q.trim()])).rows;
|
|
72
|
+
out.push(...pages.map((p) => ({ kind: 'Page', label: `${p.page_no}: ${p.name}`, detail: p.app, href: designerUrl(p.app_id, p.page_no), icon: 'file' })));
|
|
73
|
+
// a workspace without the SQL Workshop (its preference) finds no database objects
|
|
74
|
+
if (s.workspace?.sql_workshop === false) return out.filter((r) => r.kind !== 'Tool' || !r.href.startsWith(`${BASE}/sql`)).slice(0, 40);
|
|
75
|
+
const db = await workshopDb(s);
|
|
76
|
+
const objects = (await db.query<{ k: string; schema: string; name: string; ref: string }>(
|
|
77
|
+
`select * from (
|
|
78
|
+
select case c.relkind when 'v' then 'view' when 'm' then 'matview' else 'table' end as k, n.nspname as schema, c.relname as name, c.oid::regclass::text as ref
|
|
79
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
80
|
+
where c.relkind in ('r', 'p', 'v', 'm') and not c.relispartition and c.relname ilike $1
|
|
81
|
+
union all
|
|
82
|
+
select case p.prokind when 'p' then 'procedure' else 'function' end, n.nspname, p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')', p.oid::regprocedure::text
|
|
83
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
84
|
+
where p.prokind in ('f', 'p') and p.proname ilike $1
|
|
85
|
+
) x join pg_namespace n on n.nspname = x.schema
|
|
86
|
+
where x.schema !~ '^pg_' and x.schema <> 'information_schema' and not (x.schema = any ($2)) and has_schema_privilege(n.oid, 'USAGE')
|
|
87
|
+
and not exists (select 1 from pg_extension e join pg_depend d on d.refobjid = e.oid and d.deptype = 'e' where d.objid = case when x.k in ('function', 'procedure') then x.ref::regprocedure::oid else x.ref::regclass::oid end)
|
|
88
|
+
order by length(x.name), x.name limit 15`, [like, SYSTEM_SCHEMAS]).catch(() => ({ rows: [] }))).rows;
|
|
89
|
+
const KIND: Record<string, [string, string]> = { table: ['Table', 'table'], view: ['View', 'layers'], matview: ['Materialized view', 'layers'], function: ['Function', 'code'], procedure: ['Procedure', 'code'] };
|
|
90
|
+
out.push(...objects.map((o) => ({ kind: KIND[o.k][0], label: o.name, detail: o.schema, href: `${BASE}/sql/objects?${new URLSearchParams({ schema: o.schema, k: o.k, o: o.ref })}`, icon: KIND[o.k][1] })));
|
|
91
|
+
return out.slice(0, 40);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function launchpadRoutes(app: FastifyInstance) {
|
|
95
|
+
app.post(`${BASE}/pins`, async (req: Req, reply: FastifyReply) => {
|
|
96
|
+
const s = await developer(req, reply);
|
|
97
|
+
if (!s) return;
|
|
98
|
+
const b = req.body ?? {};
|
|
99
|
+
const href = String(b.href ?? b.pin_href ?? '');
|
|
100
|
+
const label = String(b.label ?? b.pin_label ?? '').trim().slice(0, 120);
|
|
101
|
+
const ic = /^[a-z0-9-]{1,40}$/.test(String(b.icon ?? '')) ? String(b.icon) : b.pin_href ? 'code' : 'bookmark';
|
|
102
|
+
const backTo = HREF_RE.test(String(b.back ?? '')) ? String(b.back) : `${BASE}/sql`;
|
|
103
|
+
if (!HREF_RE.test(href) || href.length > 400 || !label) return reply.code(400).send('Not a builder page');
|
|
104
|
+
const ws = currentWorkspace(s);
|
|
105
|
+
if (String(b.pin ?? '1') === '0') {
|
|
106
|
+
await owner.query('delete from meta.builder_pin where username = $1 and workspace_id = $2 and href = $3', [s.username, ws, href]);
|
|
107
|
+
flash(s, `Unpinned ${label}.`);
|
|
108
|
+
} else {
|
|
109
|
+
const n = Number((await owner.one<{ n: string }>('select count(*) as n from meta.builder_pin where username = $1 and workspace_id = $2', [s.username, ws]))?.n ?? 0);
|
|
110
|
+
if (n >= MAX_PINS) flash(s, `At most ${MAX_PINS} pins: unpin one first.`, 'error');
|
|
111
|
+
else {
|
|
112
|
+
await owner.query(
|
|
113
|
+
`insert into meta.builder_pin (username, workspace_id, href, label, icon) values ($1, $2, $3, $4, $5)
|
|
114
|
+
on conflict (username, workspace_id, href) do update set label = excluded.label, icon = excluded.icon`, [s.username, ws, href, label, ic]);
|
|
115
|
+
flash(s, `Pinned ${label} to the SQL Workshop home.`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return back(reply, s, backTo);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
app.get(`${BASE}/palette`, async (req: Req, reply: FastifyReply) => {
|
|
122
|
+
const s = await developer(req, reply);
|
|
123
|
+
if (!s) return;
|
|
124
|
+
const q = String(req.query?.q ?? '').trim().slice(0, 100);
|
|
125
|
+
const admin = await isAdmin(s.username);
|
|
126
|
+
const results = q ? await search(s, q, admin) : [];
|
|
127
|
+
if (req.query?.format === 'json') return reply.header('Cache-Control', 'private, no-store').send({ q, results });
|
|
128
|
+
const main = html`<h1 class="u-mb1">Search</h1>
|
|
129
|
+
<form method="get" action="${BASE}/palette" class="search u-mb1" role="search"><label class="sr-only" for="pal-q">Search</label>
|
|
130
|
+
<input id="pal-q" name="q" type="search" value="${q}" placeholder="Tables, views, functions, applications, pages, tools" autofocus><button class="btn">${icon('search')} Search</button></form>
|
|
131
|
+
${q ? results.length ? html`<ul class="pal-results">${results.map((r) => html`<li><a href="${r.href}">${icon(r.icon)}<span><strong>${r.label}</strong> <small class="muted">${r.kind} · ${r.detail}</small></span></a></li>`)}</ul>`
|
|
132
|
+
: html`<p class="muted">Nothing found for “${q}”.</p>` : html`<p class="muted">Search by name. Ctrl+K opens this search on every builder page.</p>`}`;
|
|
133
|
+
return send(reply, s, shell(s, 'Search', [['Search']], main, 'apps'));
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** For pages that show a pin for themselves (an object, a worksheet). */
|
|
138
|
+
export async function isPinned(s: Session, href: string) {
|
|
139
|
+
return !!(await owner.one('select 1 as ok from meta.builder_pin where username = $1 and workspace_id = $2 and href = $3', [s.username, currentWorkspace(s), href]));
|
|
140
|
+
}
|