domma-cms 0.101.7 → 0.101.8
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.
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SYSTEM_SLUGS=["roles","user-profiles"],FILTERS=["all","own","builtin","plugin"];const u="core";export function slugify(n){return String(n??"").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"")}export function kindOf(n){return n?.builtIn===!0||n?.preset===!0||SYSTEM_SLUGS.includes(n?.slug)?"builtin":n?.plugin?"plugin":"own"}export const projectOf=n=>n?.meta?.project||u;export function arrangeCollections(n,{filter:
|
|
1
|
+
export const SYSTEM_SLUGS=["roles","user-profiles"],FILTERS=["all","own","builtin","plugin"];const u="core";export function slugify(n){return String(n??"").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"")}export function kindOf(n){return n?.builtIn===!0||n?.preset===!0||SYSTEM_SLUGS.includes(n?.slug)?"builtin":n?.plugin?"plugin":"own"}export const projectOf=n=>n?.meta?.project||u;export function arrangeCollections(n,{filter:t="all",query:e="",project:o=null}={}){const i=String(e).trim().toLowerCase();return[...n||[]].filter(r=>!o||projectOf(r)===o).filter(r=>t==="all"||kindOf(r)===t).filter(r=>!i||[r.title,r.slug,r.description,r.plugin].some(l=>String(l||"").toLowerCase().includes(i))).sort((r,l)=>String(r.title||r.slug).localeCompare(String(l.title||l.slug)))}export function kindCounts(n,{project:t=null}={}){const e=(n||[]).filter(i=>!t||projectOf(i)===t),o={all:e.length,own:0,builtin:0,plugin:0};for(const i of e)o[kindOf(i)]++;return o}const a=(n,t,e=`${t}s`)=>`${n} ${n===1?t:e}`;export function collectionRow(n){const t=kindOf(n),e=Array.isArray(n.fields)?n.fields.length:0,o=Number(n.entryCount)||0,i=n.storage?.adapter==="mongodb",r=n.meta?.project||"";return{slug:n.slug,title:n.title||n.slug,meta:[n.slug,a(e,"field"),r?`project ${r}`:""].filter(Boolean).join(" \xB7 "),entries:o,entriesText:o.toLocaleString("en-GB"),mongo:i,mongoTitle:i?`MongoDB - connection "${n.storage.connection||"default"}"`:"",builtin:t==="builtin",bundled:t!=="builtin"&&n.bundled===!0,plugin:n.plugin||"",pluginTitle:n.plugin?`Managed by the ${n.plugin} plugin`:"",canDelete:t!=="builtin",hasForm:!SYSTEM_SLUGS.includes(n.slug)}}export function checkNewCollection(n,t=[]){const e=String(n??"").trim(),o=slugify(e);return e?o?t.includes(o)?{slug:o,error:`"${o}" is already a collection.`}:{slug:o,error:""}:{slug:o,error:"The title needs at least one letter or number."}:{slug:"",error:""}}let s=0;export function connectionRows(n){return Object.entries(n||{}).filter(([t])=>t!=="_comment").map(([t,e])=>({id:`c${++s}`,name:t,uri:String(e?.uri||""),database:String(e?.database||""),options:e?.options&&typeof e.options=="object"?e.options:{}}))}export function newConnectionRow(n){const t=new Set((n||[]).map(o=>o.name));let e=(n||[]).length+1;for(;t.has(`connection${e}`);)e++;return{id:`c${++s}`,name:e===1&&!t.has("default")?"default":`connection${e}`,uri:"",database:""}}export function connectionsFromRows(n){const t=[],e={};for(const o of n||[]){const i=String(o.name??"").trim();if(!i){t.push("Every connection needs a name.");continue}if(e[i]){t.push(`Two connections are called "${i}".`);continue}const r=String(o.uri??"").trim(),l=String(o.database??"").trim();r||t.push(`"${i}" needs a URI.`),l||t.push(`"${i}" needs a database.`),e[i]={type:"mongodb",uri:r,database:l,options:o.options&&typeof o.options=="object"?o.options:{}}}return{errors:t,config:e}}export function parseConnectionsJson(n){let t;try{t=JSON.parse(String(n??"").trim()||"{}")}catch(e){return{error:`Not valid JSON: ${e.message}`,config:null}}return!t||typeof t!="object"||Array.isArray(t)?{error:"The JSON must be an object of named connections.",config:null}:{error:"",config:t}}export function maskUri(n){return String(n??"").replace(/(\/\/[^:/@]+:)([^@]+)(@)/,"$1\u2022\u2022\u2022$3")}
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
<div class="clx-card">
|
|
41
41
|
<div class="clx-bar">
|
|
42
42
|
<strong>MongoDB connections</strong>
|
|
43
|
-
<span data-help-title="Database connections" data-help="Collections are stored as files unless their storage is set to MongoDB, which needs a connection here - usually one called default. Each needs a URI (mongodb://…) and a database name. Kept in config/connections.json; with none, every collection stays on files. Ctrl+S saves."></span>
|
|
43
|
+
<span data-help-title="Database connections" data-help="Collections are stored as files unless their storage is set to MongoDB, which needs a connection here - usually one called default. Each needs a URI (mongodb://…) and a database name. Passwords are never shown: a saved one appears as dots - leave them to keep it, or type a new one. Change the host, user or options and you must type it again. Kept in config/connections.json; with none, every collection stays on files. Ctrl+S saves."></span>
|
|
44
44
|
<span class="clx-spacer"></span>
|
|
45
45
|
<label class="clx-toggle"><input type="checkbox" data-model="raw.value"> Raw JSON</label>
|
|
46
46
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="addConnection" data-bind-hidden="raw.value">
|
|
@@ -150,11 +150,14 @@
|
|
|
150
150
|
<h3><span class="method-badge method-get">GET</span><span class="endpoint-path">/api/collections/connections</span></h3>
|
|
151
151
|
<p class="auth-note">Requires Bearer token + an admin role (level 0 or 1).</p>
|
|
152
152
|
<p>The MongoDB connections in <code>config/connections.json</code>. <code>PUT</code> saves them; each needs
|
|
153
|
-
<code>type</code>, <code>uri</code> and <code>database</code
|
|
153
|
+
<code>type</code>, <code>uri</code> and <code>database</code>. Passwords are never sent: the one in a URI, and any
|
|
154
|
+
secret-looking option, comes back as <code>••••••••</code>. Send the mask back unchanged to keep the stored value; it is restored only while scheme, user, hosts, query
|
|
155
|
+
and options are unchanged.</p>
|
|
154
156
|
<pre class="code-block"><code>// Response 200
|
|
155
|
-
{ "default": { "type": "mongodb", "uri": "mongodb://localhost:
|
|
157
|
+
{ "default": { "type": "mongodb", "uri": "mongodb://site:••••••••@localhost:27018/my_cms", "database": "my_cms" } }
|
|
156
158
|
// PUT - Error 400
|
|
157
|
-
{ "error": "Connection \"default\" requires type, uri, and database" }
|
|
159
|
+
{ "error": "Connection \"default\" requires type, uri, and database" }
|
|
160
|
+
{ "error": "Connection \"default\": type the password again - it was hidden, and the connection now points somewhere else or it was never saved" }</code></pre>
|
|
158
161
|
|
|
159
162
|
<h3 style="margin-top:24px;font-size:15px;text-transform:uppercase;letter-spacing:.5px;opacity:.6">
|
|
160
163
|
Admin Entry CRUD</h3>
|
package/package.json
CHANGED
|
@@ -63,6 +63,7 @@ import {ensureFormForCollection} from '../../services/forms.js';
|
|
|
63
63
|
import {hooks} from '../../services/hooks.js';
|
|
64
64
|
import {scopeAllows, validateToken} from '../../services/apiTokens.js';
|
|
65
65
|
import {isProjectEnabled, resolveArtefactProject} from '../../services/projects.js';
|
|
66
|
+
import {maskConnections, restoreConnections} from '../../services/connectionSecrets.js';
|
|
66
67
|
|
|
67
68
|
const ALL_PRESET_SLUGS = new Set(['roles', 'user-profiles', ...PRESET_COLLECTION_SLUGS]);
|
|
68
69
|
|
|
@@ -323,7 +324,7 @@ export async function collectionsRoutes(fastify) {
|
|
|
323
324
|
fastify.get('/collections/connections', {preHandler: [authenticate, requireAdmin]}, async () => {
|
|
324
325
|
try {
|
|
325
326
|
const {_comment, ...rest} = getConfig('connections');
|
|
326
|
-
return rest;
|
|
327
|
+
return maskConnections(rest);
|
|
327
328
|
} catch {
|
|
328
329
|
return {};
|
|
329
330
|
}
|
|
@@ -339,7 +340,11 @@ export async function collectionsRoutes(fastify) {
|
|
|
339
340
|
return reply.status(400).send({error: `Connection "${name}" requires type, uri, and database`});
|
|
340
341
|
}
|
|
341
342
|
}
|
|
342
|
-
|
|
343
|
+
let stored = {};
|
|
344
|
+
try { stored = getConfig('connections'); } catch { /* nothing saved yet */ }
|
|
345
|
+
const {config, error} = restoreConnections(data, stored);
|
|
346
|
+
if (error) return reply.status(400).send({error});
|
|
347
|
+
saveConfig('connections', stored._comment !== undefined ? {_comment: stored._comment, ...config} : config);
|
|
343
348
|
return {success: true};
|
|
344
349
|
});
|
|
345
350
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database connection secrets never reach the browser.
|
|
3
|
+
*
|
|
4
|
+
* GET /collections/connections sends each URI with its password, and any secret query
|
|
5
|
+
* parameter (tlsCertificateKeyFilePassword, proxyPassword, authMechanismProperties), replaced
|
|
6
|
+
* by CONNECTION_MASK, and any option whose key looks secret (auth.password) as the mask too.
|
|
7
|
+
* PUT sends the mask back for anything left untouched and restoreConnections() puts the stored
|
|
8
|
+
* value back - but only from a stored connection with the same endpoint (scheme, user, host
|
|
9
|
+
* list, query and driver options, secrets aside). Change where or how a connection
|
|
10
|
+
* authenticates and its secrets must be typed again, or a masked URI aimed at another host or
|
|
11
|
+
* proxy would carry the stored password there. The database path may change freely. A mask
|
|
12
|
+
* that cannot be matched is refused, never saved as the password. A URI whose credentials
|
|
13
|
+
* cannot be told apart from its query (a raw ? in the password) is masked whole.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const CONNECTION_MASK = '••••••••';
|
|
17
|
+
|
|
18
|
+
const SECRET_KEY = /pass(word)?|secret|token|api[-_]?key|private|credential/i;
|
|
19
|
+
const SECRET_PARAM = /pass(word)?|secret|token|authMechanismProperties/i;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* scheme://[user[:password]@]hosts[/path][?query]. The query is split off first and the LAST
|
|
23
|
+
* @ before it ends the credentials, so an @ in the query or an unescaped one in the password
|
|
24
|
+
* cannot move the split.
|
|
25
|
+
*/
|
|
26
|
+
function parseUri(uri) {
|
|
27
|
+
if (typeof uri !== 'string') return null;
|
|
28
|
+
const m = uri.trim().match(/^([a-z][a-z0-9+.-]*:\/\/)(.*)$/is);
|
|
29
|
+
if (!m) return null;
|
|
30
|
+
const rest = m[2];
|
|
31
|
+
const qi = rest.indexOf('?');
|
|
32
|
+
const main = qi < 0 ? rest : rest.slice(0, qi);
|
|
33
|
+
const at = main.lastIndexOf('@');
|
|
34
|
+
// An @ only after the first ? is either in the query or behind a raw ? in the password:
|
|
35
|
+
// unknowable, so the whole URI is treated as one secret.
|
|
36
|
+
if (rest === CONNECTION_MASK || (at < 0 && qi >= 0 && rest.indexOf('@', qi) >= 0)) {
|
|
37
|
+
return {opaque: true, scheme: m[1].toLowerCase(), uri: uri.trim()};
|
|
38
|
+
}
|
|
39
|
+
const userinfo = at < 0 ? null : main.slice(0, at);
|
|
40
|
+
const hp = at < 0 ? main : main.slice(at + 1);
|
|
41
|
+
const si = hp.indexOf('/');
|
|
42
|
+
const ci = userinfo === null ? -1 : userinfo.indexOf(':');
|
|
43
|
+
return {
|
|
44
|
+
scheme: m[1].toLowerCase(),
|
|
45
|
+
user: userinfo === null ? null : (ci < 0 ? userinfo : userinfo.slice(0, ci)),
|
|
46
|
+
pass: ci < 0 ? null : userinfo.slice(ci + 1),
|
|
47
|
+
hosts: si < 0 ? hp : hp.slice(0, si),
|
|
48
|
+
path: si < 0 ? '' : hp.slice(si),
|
|
49
|
+
params: qi < 0 ? null : rest.slice(qi + 1).split('&').map(p => {
|
|
50
|
+
const e = p.indexOf('=');
|
|
51
|
+
return e < 0 ? [p, null] : [p.slice(0, e), p.slice(e + 1)];
|
|
52
|
+
})
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatUri(p) {
|
|
57
|
+
if (p.opaque) return p.scheme + CONNECTION_MASK;
|
|
58
|
+
const cred = p.user === null ? '' : p.user + (p.pass === null ? '' : ':' + p.pass) + '@';
|
|
59
|
+
const query = p.params === null ? '' : '?' + p.params.map(([k, v]) => (v === null ? k : `${k}=${v}`)).join('&');
|
|
60
|
+
return p.scheme + cred + p.hosts + p.path + query;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const maskParams = (params) => params && params.map(([k, v]) => [k, v && SECRET_PARAM.test(k) ? CONNECTION_MASK : v]);
|
|
64
|
+
|
|
65
|
+
// Key order must not matter when comparing options.
|
|
66
|
+
const canonical = (v) => Array.isArray(v) ? v.map(canonical)
|
|
67
|
+
: v && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])])) : v;
|
|
68
|
+
|
|
69
|
+
/** Where and how a connection authenticates, secrets aside - whether stored secrets may follow it. */
|
|
70
|
+
function endpoint(p, options) {
|
|
71
|
+
const opts = canonical(maskOptions(options ?? {}));
|
|
72
|
+
if (p.opaque) return JSON.stringify(['opaque', p.scheme, opts]);
|
|
73
|
+
return JSON.stringify([p.scheme, p.user, p.hosts.toLowerCase(), maskParams(p.params), opts]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A URI with its password and secret query parameters masked. */
|
|
77
|
+
export function maskConnectionUri(uri) {
|
|
78
|
+
const p = parseUri(uri);
|
|
79
|
+
if (!p) return uri;
|
|
80
|
+
if (p.opaque) return formatUri(p);
|
|
81
|
+
return formatUri({...p, pass: p.pass ? CONNECTION_MASK : p.pass, params: maskParams(p.params)});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function maskOptions(value, key = '') {
|
|
85
|
+
if (Array.isArray(value)) return value.map(v => maskOptions(v, key));
|
|
86
|
+
if (value && typeof value === 'object') {
|
|
87
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, maskOptions(v, k)]));
|
|
88
|
+
}
|
|
89
|
+
return SECRET_KEY.test(key) && typeof value === 'string' && value !== '' ? CONNECTION_MASK : value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A mask with nothing stored behind it stays a mask, so the caller refuses the save.
|
|
93
|
+
function restoreOptions(incoming, stored) {
|
|
94
|
+
if (incoming === CONNECTION_MASK) return typeof stored === 'string' ? stored : incoming;
|
|
95
|
+
if (Array.isArray(incoming)) {
|
|
96
|
+
const before = Array.isArray(stored) ? stored : [];
|
|
97
|
+
return incoming.map((v, i) => restoreOptions(v, before[i]));
|
|
98
|
+
}
|
|
99
|
+
if (incoming && typeof incoming === 'object') {
|
|
100
|
+
const out = {};
|
|
101
|
+
for (const [k, v] of Object.entries(incoming)) {
|
|
102
|
+
out[k] = restoreOptions(v, stored && typeof stored === 'object' ? stored[k] : undefined);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
return incoming;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function restoreUri(incoming, stored) {
|
|
110
|
+
const pi = parseUri(incoming);
|
|
111
|
+
const ps = parseUri(stored);
|
|
112
|
+
if (pi.opaque) return ps.uri;
|
|
113
|
+
const pass = pi.pass === CONNECTION_MASK ? ps.pass : pi.pass;
|
|
114
|
+
const params = pi.params && pi.params.map(([k, v], i) => [k, v === CONNECTION_MASK ? ps.params[i][1] : v]);
|
|
115
|
+
return formatUri({...pi, pass, params});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The connections as the browser may see them. */
|
|
119
|
+
export function maskConnections(connections) {
|
|
120
|
+
const out = {};
|
|
121
|
+
for (const [name, conn] of Object.entries(connections || {})) {
|
|
122
|
+
if (!conn || typeof conn !== 'object') { out[name] = conn; continue; }
|
|
123
|
+
const masked = {...conn, uri: maskConnectionUri(conn.uri)};
|
|
124
|
+
if (conn.options !== undefined) masked.options = maskOptions(conn.options);
|
|
125
|
+
out[name] = masked;
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The connections to save: masks put back from what is stored.
|
|
132
|
+
* @returns {{config?: object, error?: string}}
|
|
133
|
+
*/
|
|
134
|
+
export function restoreConnections(incoming, stored) {
|
|
135
|
+
const before = stored && typeof stored === 'object' ? stored : {};
|
|
136
|
+
const config = {};
|
|
137
|
+
for (const [name, conn] of Object.entries(incoming)) {
|
|
138
|
+
if (!conn || typeof conn !== 'object' || !JSON.stringify(conn).includes(CONNECTION_MASK)) {
|
|
139
|
+
config[name] = conn;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const pi = parseUri(conn.uri);
|
|
143
|
+
// The same name first, then any other stored connection at the same endpoint (a rename).
|
|
144
|
+
const candidates = [before[name], ...Object.entries(before).filter(([n]) => n !== name).map(([, c]) => c)];
|
|
145
|
+
const source = pi && candidates.find(c => {
|
|
146
|
+
const ps = c && typeof c === 'object' ? parseUri(c.uri) : null;
|
|
147
|
+
return ps && endpoint(ps, c.options) === endpoint(pi, conn.options);
|
|
148
|
+
});
|
|
149
|
+
const out = {...conn};
|
|
150
|
+
if (source) {
|
|
151
|
+
out.uri = restoreUri(conn.uri, source.uri);
|
|
152
|
+
if (conn.options !== undefined) out.options = restoreOptions(conn.options, source.options);
|
|
153
|
+
}
|
|
154
|
+
if (JSON.stringify(out).includes(CONNECTION_MASK)) {
|
|
155
|
+
return {error: `Connection "${name}": type the password again - it was hidden, and the connection now points somewhere else or it was never saved`};
|
|
156
|
+
}
|
|
157
|
+
config[name] = out;
|
|
158
|
+
}
|
|
159
|
+
return {config};
|
|
160
|
+
}
|