wiki-plugin-gatekeeper 0.0.0-stage → 0.2.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/api/openapi.json +216 -0
- package/client/gatekeeper.js +42 -0
- package/client/gatekeeper.js.map +7 -0
- package/factory.json +1 -0
- package/package.json +35 -3
- package/pages/about-gatekeeper-plugin +60 -0
- package/server/package.json +3 -0
- package/server/server.js +258 -0
- package/README.md +0 -3
package/api/openapi.json
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
{
|
|
2
|
+
"openapi": "3.1.0",
|
|
3
|
+
"info": {
|
|
4
|
+
"title": "Gatekeeper",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"description": "Who may read which site of this farm. Almost all of it changes farm configuration and is admin-only, which is why it keeps its own server: every route but the first reads the caller\u2019s identity, and one of them exits the process for a supervisor to restart. None of that survives a handler that never sees the request."
|
|
7
|
+
},
|
|
8
|
+
"paths": {
|
|
9
|
+
"/plugin/gatekeeper/ping.json": {
|
|
10
|
+
"get": {
|
|
11
|
+
"x-fedwiki-served-by": "plugin",
|
|
12
|
+
"x-fedwiki-read-only": true,
|
|
13
|
+
"operationId": "ping",
|
|
14
|
+
"summary": "Is the gatekeeper here",
|
|
15
|
+
"description": "The one route open to everybody, and the only one whose answer is not about configuration. A client uses it to find out whether restriction is available at all.",
|
|
16
|
+
"responses": {
|
|
17
|
+
"200": {
|
|
18
|
+
"description": "Present, and its version."
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"/plugin/gatekeeper/status.json": {
|
|
24
|
+
"get": {
|
|
25
|
+
"x-fedwiki-served-by": "plugin",
|
|
26
|
+
"x-fedwiki-read-only": true,
|
|
27
|
+
"operationId": "status",
|
|
28
|
+
"summary": "How each site is restricted",
|
|
29
|
+
"description": "The current restriction settings for the farm. Admin only.",
|
|
30
|
+
"responses": {
|
|
31
|
+
"200": {
|
|
32
|
+
"description": "Per-site restriction settings."
|
|
33
|
+
},
|
|
34
|
+
"403": {
|
|
35
|
+
"description": "Not an admin."
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"/plugin/gatekeeper/toggle": {
|
|
41
|
+
"post": {
|
|
42
|
+
"x-fedwiki-served-by": "plugin",
|
|
43
|
+
"x-fedwiki-writes": true,
|
|
44
|
+
"x-fedwiki-private-only": true,
|
|
45
|
+
"operationId": "toggle",
|
|
46
|
+
"summary": "Restrict a site, or stop restricting it",
|
|
47
|
+
"description": "Turns login-to-view on or off for one site, and writes the farm config. Admin only.",
|
|
48
|
+
"requestBody": {
|
|
49
|
+
"required": true,
|
|
50
|
+
"content": {
|
|
51
|
+
"application/json": {
|
|
52
|
+
"schema": {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"required": [
|
|
55
|
+
"host"
|
|
56
|
+
],
|
|
57
|
+
"properties": {
|
|
58
|
+
"host": {
|
|
59
|
+
"type": "string",
|
|
60
|
+
"description": "The site being configured."
|
|
61
|
+
},
|
|
62
|
+
"restricted": {
|
|
63
|
+
"type": "boolean"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"responses": {
|
|
71
|
+
"200": {
|
|
72
|
+
"description": "Written."
|
|
73
|
+
},
|
|
74
|
+
"403": {
|
|
75
|
+
"description": "Not an admin."
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"/plugin/gatekeeper/allowed": {
|
|
81
|
+
"post": {
|
|
82
|
+
"x-fedwiki-served-by": "plugin",
|
|
83
|
+
"x-fedwiki-writes": true,
|
|
84
|
+
"x-fedwiki-private-only": true,
|
|
85
|
+
"operationId": "allowed",
|
|
86
|
+
"summary": "Who may read a restricted site",
|
|
87
|
+
"description": "Sets the allowed identities and domains for a site, and the splash page shown to everyone else. Admin only.",
|
|
88
|
+
"requestBody": {
|
|
89
|
+
"required": true,
|
|
90
|
+
"content": {
|
|
91
|
+
"application/json": {
|
|
92
|
+
"schema": {
|
|
93
|
+
"type": "object",
|
|
94
|
+
"required": [
|
|
95
|
+
"host"
|
|
96
|
+
],
|
|
97
|
+
"properties": {
|
|
98
|
+
"host": {
|
|
99
|
+
"type": "string",
|
|
100
|
+
"description": "The site being configured."
|
|
101
|
+
},
|
|
102
|
+
"allowed_ids": {
|
|
103
|
+
"type": "array",
|
|
104
|
+
"items": {
|
|
105
|
+
"type": "string"
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
"allowed_domains": {
|
|
109
|
+
"type": "array",
|
|
110
|
+
"items": {
|
|
111
|
+
"type": "string"
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
"splash_page": {
|
|
115
|
+
"type": "string"
|
|
116
|
+
},
|
|
117
|
+
"details": {
|
|
118
|
+
"type": "string"
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
"responses": {
|
|
126
|
+
"200": {
|
|
127
|
+
"description": "Written."
|
|
128
|
+
},
|
|
129
|
+
"403": {
|
|
130
|
+
"description": "Not an admin."
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"/plugin/gatekeeper/site": {
|
|
136
|
+
"post": {
|
|
137
|
+
"x-fedwiki-served-by": "plugin",
|
|
138
|
+
"x-fedwiki-writes": true,
|
|
139
|
+
"x-fedwiki-private-only": true,
|
|
140
|
+
"operationId": "site",
|
|
141
|
+
"summary": "Add or remove a site",
|
|
142
|
+
"description": "Creates or drops a site entry in the farm configuration. Admin only.",
|
|
143
|
+
"requestBody": {
|
|
144
|
+
"required": true,
|
|
145
|
+
"content": {
|
|
146
|
+
"application/json": {
|
|
147
|
+
"schema": {
|
|
148
|
+
"type": "object",
|
|
149
|
+
"required": [
|
|
150
|
+
"host",
|
|
151
|
+
"action"
|
|
152
|
+
],
|
|
153
|
+
"properties": {
|
|
154
|
+
"host": {
|
|
155
|
+
"type": "string",
|
|
156
|
+
"description": "The site being configured."
|
|
157
|
+
},
|
|
158
|
+
"action": {
|
|
159
|
+
"type": "string"
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
"responses": {
|
|
167
|
+
"200": {
|
|
168
|
+
"description": "Written."
|
|
169
|
+
},
|
|
170
|
+
"403": {
|
|
171
|
+
"description": "Not an admin."
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
"/plugin/gatekeeper/restart": {
|
|
177
|
+
"post": {
|
|
178
|
+
"x-fedwiki-served-by": "plugin",
|
|
179
|
+
"x-fedwiki-writes": true,
|
|
180
|
+
"x-fedwiki-private-only": true,
|
|
181
|
+
"operationId": "restart",
|
|
182
|
+
"summary": "Restart the wiki",
|
|
183
|
+
"description": "Exits the process so a supervisor restarts it, which is how configuration changes take effect. Refused unless the server is actually supervised, because otherwise it would stop the wiki and nothing would bring it back. The single clearest example of a route that can never be a farm-mounted operation.",
|
|
184
|
+
"requestBody": {
|
|
185
|
+
"content": {
|
|
186
|
+
"application/json": {
|
|
187
|
+
"schema": {
|
|
188
|
+
"type": "object",
|
|
189
|
+
"properties": {
|
|
190
|
+
"force": {
|
|
191
|
+
"type": "boolean",
|
|
192
|
+
"description": "Exit even when no supervisor is detected."
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
"responses": {
|
|
200
|
+
"200": {
|
|
201
|
+
"description": "Exiting."
|
|
202
|
+
},
|
|
203
|
+
"403": {
|
|
204
|
+
"description": "Not an admin."
|
|
205
|
+
},
|
|
206
|
+
"409": {
|
|
207
|
+
"description": "Not supervised, and force was not set."
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
"components": {
|
|
214
|
+
"schemas": {}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
(()=>{var l=(o,p)=>fetch(`/plugin/gatekeeper/${o}`,{method:"POST",headers:{"Content-Type":"application/json"},credentials:"include",body:JSON.stringify(p||{})}),c=o=>String(o).replace(/[&<>"]/g,p=>({"&":"&","<":"<",">":">",'"':"""})[p]),k=`
|
|
2
|
+
.gatekeeper { font-size: 90%; }
|
|
3
|
+
.gatekeeper table { width: 100%; border-collapse: collapse; }
|
|
4
|
+
.gatekeeper th, .gatekeeper td { text-align: left; padding: 3px 6px; border-bottom: 1px solid #eee; }
|
|
5
|
+
.gatekeeper .pending { background: #f5a623; color: #fff; border-radius: 8px; padding: 0 7px; font-size: 85%; }
|
|
6
|
+
.gatekeeper .warning { color: #c0392b; font-size: 85%; }
|
|
7
|
+
.gatekeeper .ids, .gatekeeper .pub, .gatekeeper .terms { cursor: pointer; border-bottom: 1px dotted #999; }
|
|
8
|
+
.gatekeeper .terms { font-size: 85%; color: #888; }
|
|
9
|
+
.gatekeeper .remove { cursor: pointer; color: #c0392b; border: 0; background: none; }
|
|
10
|
+
.gatekeeper .bar { display: flex; gap: 6px; margin-top: 8px; align-items: center; flex-wrap: wrap; }
|
|
11
|
+
.gatekeeper input[type=text] { flex: 1; min-width: 10em; padding: 2px 6px; }
|
|
12
|
+
.gatekeeper button { padding: 2px 10px; }
|
|
13
|
+
.gatekeeper .status { margin-top: 6px; font-size: 85%; color: #666; min-height: 1.2em; }
|
|
14
|
+
.gatekeeper .status.err { color: #c0392b; }
|
|
15
|
+
.gatekeeper .status.ok { color: #27ae60; }
|
|
16
|
+
`,b=(o,p)=>{o.html(`<style>${k}</style><div class="gatekeeper"><p>Gatekeeper \u2014 loading\u2026</p></div>`)},w=(o,p)=>{let g=()=>o.find(".gatekeeper"),n=(e,t)=>g().find(".status").attr("class",`status ${t||""}`).text(e),f=e=>{let t=(e.sites||[]).map(s=>{if(s.removed)return`<tr><td>${c(s.host)}</td><td colspan="4"><em>removed from config</em></td>
|
|
17
|
+
<td><span class="pending">pending restart</span></td><td></td></tr>`;let i=(s.public_pages||[]).join(", "),a=s.require_terms||"";return`<tr data-host="${c(s.host)}">
|
|
18
|
+
<td>${c(s.host)}</td>
|
|
19
|
+
<td><input type="checkbox" class="restricted" ${s.restricted?"checked":""}></td>
|
|
20
|
+
<td><span class="ids" title="click to edit allowed ids">${c((s.allowed_ids||[]).join(" ")||"\u2014")}</span></td>
|
|
21
|
+
<td><span class="pub" title="click to edit the public (blessed) pages">${c(i||"\u2014")}</span>
|
|
22
|
+
<div><span class="terms" title="click to set the must-read-before-login page">terms: ${c(a||"\u2014")}</span></div></td>
|
|
23
|
+
<td>${s.warnings.map(h=>`<div class="warning">\u26A0 ${c(h)}</div>`).join("")}</td>
|
|
24
|
+
<td>${s.pending?'<span class="pending">pending restart</span>':""}</td>
|
|
25
|
+
<td><button class="remove" title="remove site from config">\xD7</button></td>
|
|
26
|
+
</tr>`}).join(""),r=e.configEditedSinceBoot||(e.sites||[]).some(s=>s.pending);g().html(`
|
|
27
|
+
<table>
|
|
28
|
+
<tr><th>site</th><th>login-to-view</th><th>allowed ids</th><th>public pages</th><th></th><th></th><th></th></tr>
|
|
29
|
+
${t||'<tr><td colspan="7"><em>no wikiDomains entries in the config yet</em></td></tr>'}
|
|
30
|
+
</table>
|
|
31
|
+
<div class="bar">
|
|
32
|
+
<input type="text" class="new-host" placeholder="add a site, e.g. private.example.com">
|
|
33
|
+
<button class="add">add</button>
|
|
34
|
+
${r?'<button class="restart">restart farm</button>':""}
|
|
35
|
+
</div>
|
|
36
|
+
<div class="status"></div>
|
|
37
|
+
`),r&&n(e.supervised?`Config changed since the farm started \u2014 restart to apply (${c(e.supervisor||"supervised")} will bring it back).`:"Config changed \u2014 no supervisor detected; restart with wiki-start --restart (button will offer force).")},d=()=>fetch("/plugin/gatekeeper/status.json",{credentials:"include",cache:"no-store"}).then(e=>{if(e.status===403)throw Object.assign(new Error("admin"),{admin:!0});return e.json()}).then(e=>{if(e.error&&e.readOnly){g().html(`<p><em>${c(e.error)}</em></p>`);return}m=e.processStarted,f(e)}).catch(e=>{e.admin?g().html("<p><em>Gatekeeper needs an admin login \u2014 log in with the site secret first.</em></p>"):g().html(`<p><em>gatekeeper unreachable: ${c(e.message)}</em></p>`)}),m=null,u=()=>{let e=Date.now();n("restarting\u2026");let t=()=>{if(Date.now()-e>12e4){n("server did not come back within 2 minutes \u2014 check the supervisor.","err");return}fetch("/plugin/gatekeeper/ping.json",{cache:"no-store"}).then(r=>r.json()).then(r=>{r.started&&r.started!==m?(n("back up \u2713","ok"),setTimeout(d,800)):setTimeout(t,2e3)}).catch(()=>setTimeout(t,2e3))};setTimeout(t,2e3)};o.on("change",".restricted",e=>{let t=e.target.closest("tr").dataset.host;l("toggle",{host:t,restricted:e.target.checked}).then(r=>r.ok?d():r.json().then(s=>n(s.error,"err"))).catch(r=>n(r.message,"err"))}),o.on("click",".ids",e=>{let t=e.target.closest("tr").dataset.host,r=e.target.textContent==="\u2014"?"":e.target.textContent,s=prompt(`Allowed ids for ${t} \u2014 space-separated ("*" = any logged-in user, empty = clear):`,r);if(s==null)return;let i=s.trim()?s.trim().split(/[\s]+/):[];l("allowed",{host:t,allowed_ids:i}).then(a=>a.ok?d():a.json().then(h=>n(h.error,"err"))).catch(a=>n(a.message,"err"))}),o.on("click",".pub",e=>{let t=e.target.closest("tr").dataset.host,r=e.target.textContent==="\u2014"?"":e.target.textContent,s=prompt(`Public (blessed) pages for ${t} \u2014 comma-separated page names a logged-out visitor may read (empty = splash only):`,r);if(s==null)return;let i=s.trim()?s.split(",").map(a=>a.trim()).filter(Boolean):[];l("allowed",{host:t,public_pages:i}).then(a=>a.ok?d():a.json().then(h=>n(h.error,"err"))).catch(a=>n(a.message,"err"))}),o.on("click",".terms",e=>{let t=e.target.closest("tr").dataset.host,r=e.target.textContent.replace(/^terms:\s*/,""),s=prompt(`Must-read-before-login page for ${t} \u2014 a page name, "true" for site-terms, empty to clear:`,r==="\u2014"?"":r);s!=null&&l("allowed",{host:t,require_terms:s.trim()}).then(i=>i.ok?d():i.json().then(a=>n(a.error,"err"))).catch(i=>n(i.message,"err"))}),o.on("click",".remove",e=>{let t=e.target.closest("tr").dataset.host;confirm(`Remove ${t} from the farm config's wikiDomains? (site data is untouched)`)&&l("site",{host:t,action:"remove"}).then(r=>r.ok?d():r.json().then(s=>n(s.error,"err"))).catch(r=>n(r.message,"err"))}),o.on("click",".add",()=>{let e=(o.find(".new-host").val()||"").trim().toLowerCase();e&&l("site",{host:e,action:"add"}).then(t=>t.ok?d():t.json().then(r=>n(r.error,"err"))).catch(t=>n(t.message,"err"))}),o.on("click",".restart",()=>{o.find("button, input").attr("disabled","disabled"),l("restart").then(e=>e.ok?u():e.status===409?e.json().then(t=>{confirm(`${t.error}
|
|
38
|
+
|
|
39
|
+
${t.hint}
|
|
40
|
+
|
|
41
|
+
Force the exit anyway?`)?l("restart",{force:!0}).then(r=>r.ok?u():n("restart refused","err")):(o.find("button, input").removeAttr("disabled"),n("restart cancelled \u2014 use wiki-start --restart from a shell.","err"))}):e.json().then(t=>n(t.error||"restart failed","err")))}),d()};typeof window<"u"&&(window.plugins=window.plugins||{},window.plugins.gatekeeper={emit:b,bind:w});})();
|
|
42
|
+
//# sourceMappingURL=gatekeeper.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/client/gatekeeper.js"],
|
|
4
|
+
"sourcesContent": ["// Gatekeeper item \u2014 farm login-to-view control panel.\n// Talks to /plugin/gatekeeper/* on the origin serving the page. All writes\n// are admin-gated server-side; non-admins get a read-only notice.\n\nconst post = (endpoint, body) =>\n fetch(`/plugin/gatekeeper/${endpoint}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n credentials: 'include',\n body: JSON.stringify(body || {}),\n })\n\nconst esc = s =>\n String(s).replace(/[&<>\"]/g, c => ({ '&': '&', '<': '<', '>': '>', '\"': '"' }[c]))\n\nconst styles = `\n .gatekeeper { font-size: 90%; }\n .gatekeeper table { width: 100%; border-collapse: collapse; }\n .gatekeeper th, .gatekeeper td { text-align: left; padding: 3px 6px; border-bottom: 1px solid #eee; }\n .gatekeeper .pending { background: #f5a623; color: #fff; border-radius: 8px; padding: 0 7px; font-size: 85%; }\n .gatekeeper .warning { color: #c0392b; font-size: 85%; }\n .gatekeeper .ids, .gatekeeper .pub, .gatekeeper .terms { cursor: pointer; border-bottom: 1px dotted #999; }\n .gatekeeper .terms { font-size: 85%; color: #888; }\n .gatekeeper .remove { cursor: pointer; color: #c0392b; border: 0; background: none; }\n .gatekeeper .bar { display: flex; gap: 6px; margin-top: 8px; align-items: center; flex-wrap: wrap; }\n .gatekeeper input[type=text] { flex: 1; min-width: 10em; padding: 2px 6px; }\n .gatekeeper button { padding: 2px 10px; }\n .gatekeeper .status { margin-top: 6px; font-size: 85%; color: #666; min-height: 1.2em; }\n .gatekeeper .status.err { color: #c0392b; }\n .gatekeeper .status.ok { color: #27ae60; }\n`\n\nconst emit = (div, item) => {\n div.html(`<style>${styles}</style><div class=\"gatekeeper\"><p>Gatekeeper \u2014 loading\u2026</p></div>`)\n}\n\nconst bind = (div, item) => {\n const root = () => div.find('.gatekeeper')\n const say = (msg, cls) => root().find('.status').attr('class', `status ${cls || ''}`).text(msg)\n\n const render = data => {\n const rows = (data.sites || [])\n .map(site => {\n if (site.removed)\n return `<tr><td>${esc(site.host)}</td><td colspan=\"4\"><em>removed from config</em></td>\n <td><span class=\"pending\">pending restart</span></td><td></td></tr>`\n const pub = (site.public_pages || []).join(', ')\n const terms = site.require_terms || ''\n return `<tr data-host=\"${esc(site.host)}\">\n <td>${esc(site.host)}</td>\n <td><input type=\"checkbox\" class=\"restricted\" ${site.restricted ? 'checked' : ''}></td>\n <td><span class=\"ids\" title=\"click to edit allowed ids\">${esc((site.allowed_ids || []).join(' ') || '\u2014')}</span></td>\n <td><span class=\"pub\" title=\"click to edit the public (blessed) pages\">${esc(pub || '\u2014')}</span>\n <div><span class=\"terms\" title=\"click to set the must-read-before-login page\">terms: ${esc(terms || '\u2014')}</span></div></td>\n <td>${site.warnings.map(w => `<div class=\"warning\">\u26A0 ${esc(w)}</div>`).join('')}</td>\n <td>${site.pending ? '<span class=\"pending\">pending restart</span>' : ''}</td>\n <td><button class=\"remove\" title=\"remove site from config\">\u00D7</button></td>\n </tr>`\n })\n .join('')\n const anyPending =\n data.configEditedSinceBoot || (data.sites || []).some(site => site.pending)\n root().html(`\n <table>\n <tr><th>site</th><th>login-to-view</th><th>allowed ids</th><th>public pages</th><th></th><th></th><th></th></tr>\n ${rows || '<tr><td colspan=\"7\"><em>no wikiDomains entries in the config yet</em></td></tr>'}\n </table>\n <div class=\"bar\">\n <input type=\"text\" class=\"new-host\" placeholder=\"add a site, e.g. private.example.com\">\n <button class=\"add\">add</button>\n ${anyPending ? '<button class=\"restart\">restart farm</button>' : ''}\n </div>\n <div class=\"status\"></div>\n `)\n if (anyPending)\n say(\n data.supervised\n ? `Config changed since the farm started \u2014 restart to apply (${esc(data.supervisor || 'supervised')} will bring it back).`\n : 'Config changed \u2014 no supervisor detected; restart with wiki-start --restart (button will offer force).'\n )\n }\n\n const refresh = () =>\n fetch('/plugin/gatekeeper/status.json', { credentials: 'include', cache: 'no-store' })\n .then(r => {\n if (r.status === 403) throw Object.assign(new Error('admin'), { admin: true })\n return r.json()\n })\n .then(data => {\n if (data.error && data.readOnly) {\n root().html(`<p><em>${esc(data.error)}</em></p>`)\n return\n }\n lastStarted = data.processStarted\n render(data)\n })\n .catch(e => {\n if (e.admin)\n root().html('<p><em>Gatekeeper needs an admin login \u2014 log in with the site secret first.</em></p>')\n else root().html(`<p><em>gatekeeper unreachable: ${esc(e.message)}</em></p>`)\n })\n\n let lastStarted = null\n\n const pollUntilBack = () => {\n const began = Date.now()\n say('restarting\u2026')\n const tick = () => {\n if (Date.now() - began > 120000) {\n say('server did not come back within 2 minutes \u2014 check the supervisor.', 'err')\n return\n }\n fetch('/plugin/gatekeeper/ping.json', { cache: 'no-store' })\n .then(r => r.json())\n .then(pong => {\n if (pong.started && pong.started !== lastStarted) {\n say('back up \u2713', 'ok')\n setTimeout(refresh, 800)\n } else setTimeout(tick, 2000)\n })\n .catch(() => setTimeout(tick, 2000))\n }\n setTimeout(tick, 2000)\n }\n\n div.on('change', '.restricted', e => {\n const host = e.target.closest('tr').dataset.host\n post('toggle', { host, restricted: e.target.checked })\n .then(r => (r.ok ? refresh() : r.json().then(b => say(b.error, 'err'))))\n .catch(e2 => say(e2.message, 'err'))\n })\n\n div.on('click', '.ids', e => {\n const host = e.target.closest('tr').dataset.host\n const current = e.target.textContent === '\u2014' ? '' : e.target.textContent\n const entered = prompt(\n `Allowed ids for ${host} \u2014 space-separated (\"*\" = any logged-in user, empty = clear):`,\n current\n )\n if (entered == null) return\n const allowed_ids = entered.trim() ? entered.trim().split(/[\\s]+/) : []\n post('allowed', { host, allowed_ids })\n .then(r => (r.ok ? refresh() : r.json().then(b => say(b.error, 'err'))))\n .catch(e2 => say(e2.message, 'err'))\n })\n\n div.on('click', '.pub', e => {\n const host = e.target.closest('tr').dataset.host\n const current = e.target.textContent === '\u2014' ? '' : e.target.textContent\n const entered = prompt(\n `Public (blessed) pages for ${host} \u2014 comma-separated page names a logged-out visitor may read (empty = splash only):`,\n current\n )\n if (entered == null) return\n const public_pages = entered.trim()\n ? entered.split(',').map(s => s.trim()).filter(Boolean)\n : []\n post('allowed', { host, public_pages })\n .then(r => (r.ok ? refresh() : r.json().then(b => say(b.error, 'err'))))\n .catch(e2 => say(e2.message, 'err'))\n })\n\n div.on('click', '.terms', e => {\n const host = e.target.closest('tr').dataset.host\n const current = e.target.textContent.replace(/^terms:\\s*/, '')\n const entered = prompt(\n `Must-read-before-login page for ${host} \u2014 a page name, \"true\" for site-terms, empty to clear:`,\n current === '\u2014' ? '' : current\n )\n if (entered == null) return\n post('allowed', { host, require_terms: entered.trim() })\n .then(r => (r.ok ? refresh() : r.json().then(b => say(b.error, 'err'))))\n .catch(e2 => say(e2.message, 'err'))\n })\n\n div.on('click', '.remove', e => {\n const host = e.target.closest('tr').dataset.host\n if (!confirm(`Remove ${host} from the farm config's wikiDomains? (site data is untouched)`)) return\n post('site', { host, action: 'remove' })\n .then(r => (r.ok ? refresh() : r.json().then(b => say(b.error, 'err'))))\n .catch(e2 => say(e2.message, 'err'))\n })\n\n div.on('click', '.add', () => {\n const host = (div.find('.new-host').val() || '').trim().toLowerCase()\n if (!host) return\n post('site', { host, action: 'add' })\n .then(r => (r.ok ? refresh() : r.json().then(b => say(b.error, 'err'))))\n .catch(e2 => say(e2.message, 'err'))\n })\n\n div.on('click', '.restart', () => {\n div.find('button, input').attr('disabled', 'disabled')\n post('restart').then(r => {\n if (r.ok) return pollUntilBack()\n if (r.status === 409)\n return r.json().then(b => {\n if (confirm(`${b.error}\\n\\n${b.hint}\\n\\nForce the exit anyway?`))\n post('restart', { force: true }).then(r2 =>\n r2.ok ? pollUntilBack() : say('restart refused', 'err')\n )\n else {\n div.find('button, input').removeAttr('disabled')\n say('restart cancelled \u2014 use wiki-start --restart from a shell.', 'err')\n }\n })\n return r.json().then(b => say(b.error || 'restart failed', 'err'))\n })\n })\n\n refresh()\n}\n\nif (typeof window !== 'undefined') {\n window.plugins = window.plugins || {}\n window.plugins['gatekeeper'] = { emit, bind }\n}\n\nexport { emit, bind }\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["post", "endpoint", "body", "esc", "s", "c", "styles", "emit", "div", "item", "bind", "root", "say", "msg", "cls", "render", "data", "rows", "site", "pub", "terms", "w", "anyPending", "refresh", "r", "lastStarted", "pollUntilBack", "began", "tick", "pong", "host", "b", "e2", "current", "entered", "allowed_ids", "public_pages", "r2"]
|
|
7
|
+
}
|
package/factory.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"name": "Gatekeeper", "title": "Toggle login-to-view for farm sites and restart the farm", "category": "other"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-plugin-gatekeeper",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Federated Wiki farm admin item: list and toggle login-to-view (restricted) sites in the farm config, edit allowed ids, add or remove sites, and restart the farm with proper back-up detection.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "client/gatekeeper.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"api",
|
|
9
|
+
"client",
|
|
10
|
+
"factory.json",
|
|
11
|
+
"pages",
|
|
12
|
+
"server"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "node scripts/build.js",
|
|
16
|
+
"test": "node --test"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"esbuild": "^0.25.0"
|
|
21
|
+
},
|
|
22
|
+
"author": "David Bovill",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-gatekeeper.git"
|
|
27
|
+
},
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-gatekeeper/issues"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-gatekeeper#readme",
|
|
32
|
+
"fedwiki": {
|
|
33
|
+
"api": {
|
|
34
|
+
"version": 1,
|
|
35
|
+
"spec": "api/openapi.json"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
6
38
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "About Gatekeeper Plugin",
|
|
3
|
+
"story": [
|
|
4
|
+
{
|
|
5
|
+
"type": "markdown",
|
|
6
|
+
"id": "f1a2b3c4d5e60718",
|
|
7
|
+
"text": "The Gatekeeper item manages **login-to-view** across a wiki farm: it lists every site in the farm config's wikiDomains map, toggles the `restricted` flag, edits `allowed_ids`, blesses `public_pages` and a `require_terms` page for the logged-out public face, adds or removes sites, and restarts the farm so the changes take effect."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"type": "gatekeeper",
|
|
11
|
+
"id": "a2b3c4d5e6f70819",
|
|
12
|
+
"text": ""
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"type": "markdown",
|
|
16
|
+
"id": "b3c4d5e6f708192a",
|
|
17
|
+
"text": "# How it works\n\nThe server side reads and writes the farm config file (the `--config` path), with a timestamped backup in `config-backups/` beside it and an atomic same-directory rename so sync clients never see a half-written file. Un-restricting **deletes** the `restricted` key, because upstream security modules check mere presence of the key.\n\nConfig changes only take effect after a farm restart (per-site servers are cached at first request), so each edited site shows a *pending restart* badge. The restart button exits the process Plugmatic-style and lets the supervisor respawn it \u2014 then polls until the server is back, proving the restart by watching the process start-time change. On an unsupervised farm (plain `wiki-start`) the exit is refused with a hint to run `wiki-start --restart` instead.\n\nAll writes require an **admin** session; the enforcement itself lives in the security modules via the shared `wiki-view-gate` module, which also serves the customisable splash screen (see the `splash_page` config key and `/assets/splash/index.html`).\n\nThe **public face** columns bless pages a logged-out visitor may read: `public_pages` serves those pages through the real client shell with a filtered sitemap, and `require_terms` names the page the splash insists on before showing its login form. Both live in the same wikiDomains entry and follow the same pending-restart flow."
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"journal": [
|
|
21
|
+
{
|
|
22
|
+
"type": "create",
|
|
23
|
+
"item": {
|
|
24
|
+
"title": "About Gatekeeper Plugin",
|
|
25
|
+
"story": [
|
|
26
|
+
{
|
|
27
|
+
"type": "markdown",
|
|
28
|
+
"id": "f1a2b3c4d5e60718",
|
|
29
|
+
"text": "The Gatekeeper item manages **login-to-view** across a wiki farm: it lists every site in the farm config's wikiDomains map, toggles the `restricted` flag, edits `allowed_ids`, adds or removes sites, and restarts the farm so the changes take effect."
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"date": 1753113600000,
|
|
34
|
+
"provenance": "from marvin"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"type": "edit",
|
|
38
|
+
"id": "f1a2b3c4d5e60718",
|
|
39
|
+
"item": {
|
|
40
|
+
"type": "markdown",
|
|
41
|
+
"id": "f1a2b3c4d5e60718",
|
|
42
|
+
"text": "The Gatekeeper item manages **login-to-view** across a wiki farm: it lists every site in the farm config's wikiDomains map, toggles the `restricted` flag, edits `allowed_ids`, blesses `public_pages` and a `require_terms` page for the logged-out public face, adds or removes sites, and restarts the farm so the changes take effect."
|
|
43
|
+
},
|
|
44
|
+
"date": 1785705961585,
|
|
45
|
+
"provenance": "Edited through a dialogue between David Bovill and Claude Code, session 2026-08-02: Gatekeeper public-face rework."
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"type": "edit",
|
|
49
|
+
"id": "b3c4d5e6f708192a",
|
|
50
|
+
"item": {
|
|
51
|
+
"type": "markdown",
|
|
52
|
+
"id": "b3c4d5e6f708192a",
|
|
53
|
+
"text": "# How it works\n\nThe server side reads and writes the farm config file (the `--config` path), with a timestamped backup in `config-backups/` beside it and an atomic same-directory rename so sync clients never see a half-written file. Un-restricting **deletes** the `restricted` key, because upstream security modules check mere presence of the key.\n\nConfig changes only take effect after a farm restart (per-site servers are cached at first request), so each edited site shows a *pending restart* badge. The restart button exits the process Plugmatic-style and lets the supervisor respawn it \u2014 then polls until the server is back, proving the restart by watching the process start-time change. On an unsupervised farm (plain `wiki-start`) the exit is refused with a hint to run `wiki-start --restart` instead.\n\nAll writes require an **admin** session; the enforcement itself lives in the security modules via the shared `wiki-view-gate` module, which also serves the customisable splash screen (see the `splash_page` config key and `/assets/splash/index.html`).\n\nThe **public face** columns bless pages a logged-out visitor may read: `public_pages` serves those pages through the real client shell with a filtered sitemap, and `require_terms` names the page the splash insists on before showing its login form. Both live in the same wikiDomains entry and follow the same pending-restart flow."
|
|
54
|
+
},
|
|
55
|
+
"date": 1785705961585,
|
|
56
|
+
"provenance": "Edited through a dialogue between David Bovill and Claude Code, session 2026-08-02: Gatekeeper public-face rework."
|
|
57
|
+
}
|
|
58
|
+
],
|
|
59
|
+
"plugin": "gatekeeper"
|
|
60
|
+
}
|
package/server/server.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// server/server.js — CommonJS (see sibling server/package.json).
|
|
2
|
+
// Gatekeeper: reads and writes the FARM config file (argv.config) to manage
|
|
3
|
+
// per-site login-to-view (the wikiDomains map's `restricted` keys), and
|
|
4
|
+
// restarts the farm Plugmatic-style (exit 0, supervisor respawns).
|
|
5
|
+
//
|
|
6
|
+
// Module-level state is shared by every per-site server in the farm process
|
|
7
|
+
// via the require cache: one snapshot of the config as the process booted,
|
|
8
|
+
// one write queue so concurrent edits never interleave.
|
|
9
|
+
|
|
10
|
+
const fs = require('node:fs')
|
|
11
|
+
const path = require('node:path')
|
|
12
|
+
|
|
13
|
+
const PROCESS_STARTED = Date.now() - process.uptime() * 1000
|
|
14
|
+
const BACKUPS_TO_KEEP = 20
|
|
15
|
+
|
|
16
|
+
// Keys the toggle/allowed routes may touch inside a wikiDomains entry.
|
|
17
|
+
const SITE_KEYS = [
|
|
18
|
+
'restricted',
|
|
19
|
+
'allowed_ids',
|
|
20
|
+
'allowed_domains',
|
|
21
|
+
'splash_page',
|
|
22
|
+
'details',
|
|
23
|
+
'public_pages',
|
|
24
|
+
'require_terms',
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
let configPath = null
|
|
28
|
+
let bootSnapshot = null // config parsed when this module first loaded
|
|
29
|
+
|
|
30
|
+
// Serialise all read-modify-write cycles.
|
|
31
|
+
let writeQueue = Promise.resolve()
|
|
32
|
+
|
|
33
|
+
const readConfig = () => JSON.parse(fs.readFileSync(configPath, 'utf8'))
|
|
34
|
+
|
|
35
|
+
const detectIndent = raw => {
|
|
36
|
+
const m = raw.match(/^(\s+)"/m)
|
|
37
|
+
return m ? m[1] : ' '
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A supervisor respawns the process after exit 0: a container runtime, a
|
|
41
|
+
// systemd service (systemd sets INVOCATION_ID for every unit it runs; the
|
|
42
|
+
// unit must carry Restart=always, as the Pi5 farms do), or an operator
|
|
43
|
+
// saying so with WIKI_SUPERVISED=1. Named so the client can show which.
|
|
44
|
+
const supervisor = () =>
|
|
45
|
+
process.env.WIKI_SUPERVISED === '1'
|
|
46
|
+
? 'env'
|
|
47
|
+
: fs.existsSync('/.dockerenv')
|
|
48
|
+
? 'docker'
|
|
49
|
+
: process.env.INVOCATION_ID
|
|
50
|
+
? 'systemd'
|
|
51
|
+
: null
|
|
52
|
+
const supervised = () => supervisor() != null
|
|
53
|
+
|
|
54
|
+
const writeConfig = mutator =>
|
|
55
|
+
(writeQueue = writeQueue.then(() => {
|
|
56
|
+
const raw = fs.readFileSync(configPath, 'utf8')
|
|
57
|
+
const config = JSON.parse(raw)
|
|
58
|
+
mutator(config)
|
|
59
|
+
|
|
60
|
+
const dir = path.dirname(configPath)
|
|
61
|
+
const backups = path.join(dir, 'config-backups')
|
|
62
|
+
fs.mkdirSync(backups, { recursive: true })
|
|
63
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
64
|
+
fs.copyFileSync(configPath, path.join(backups, `${path.basename(configPath)}.${stamp}`))
|
|
65
|
+
const old = fs
|
|
66
|
+
.readdirSync(backups)
|
|
67
|
+
.filter(f => f.startsWith(path.basename(configPath)))
|
|
68
|
+
.sort()
|
|
69
|
+
for (const f of old.slice(0, Math.max(0, old.length - BACKUPS_TO_KEEP)))
|
|
70
|
+
fs.rmSync(path.join(backups, f))
|
|
71
|
+
|
|
72
|
+
// Same-directory tmp + rename = atomic on one filesystem; sync clients
|
|
73
|
+
// (Nextcloud) never observe a half-written config.
|
|
74
|
+
const tmp = path.join(dir, `.${path.basename(configPath)}.gatekeeper-tmp`)
|
|
75
|
+
fs.writeFileSync(tmp, JSON.stringify(config, null, detectIndent(raw)) + '\n')
|
|
76
|
+
fs.renameSync(tmp, configPath)
|
|
77
|
+
return config
|
|
78
|
+
}))
|
|
79
|
+
|
|
80
|
+
const validHost = h => typeof h === 'string' && /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i.test(h)
|
|
81
|
+
const validEntry = s => typeof s === 'string' && s.length > 0 && !s.includes(',')
|
|
82
|
+
|
|
83
|
+
const lint = site => {
|
|
84
|
+
const warnings = []
|
|
85
|
+
for (const key of ['allowed_ids', 'allowed_domains']) {
|
|
86
|
+
const list = site[key]
|
|
87
|
+
if (list == null) continue
|
|
88
|
+
if (!Array.isArray(list)) warnings.push(`${key} is not an array`)
|
|
89
|
+
else if (list.some(e => typeof e === 'string' && e.includes(',')))
|
|
90
|
+
warnings.push(`${key} has a comma inside one entry — split it into separate entries; it can never match as-is`)
|
|
91
|
+
}
|
|
92
|
+
if (site.restricted != null && site.restricted !== 'true' && site.restricted !== 'terms')
|
|
93
|
+
warnings.push(`restricted is ${JSON.stringify(site.restricted)} — any value restricts (upstream checks mere presence); use "true", "terms", or delete the key`)
|
|
94
|
+
if (site.public_pages != null && !Array.isArray(site.public_pages))
|
|
95
|
+
warnings.push('public_pages is not an array')
|
|
96
|
+
return warnings
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const validSlugName = s => typeof s === 'string' && s.trim().length > 0 && !s.includes(',')
|
|
100
|
+
|
|
101
|
+
const startServer = ({ argv, app }) => {
|
|
102
|
+
if (!configPath && argv.config) {
|
|
103
|
+
configPath = argv.config
|
|
104
|
+
try {
|
|
105
|
+
bootSnapshot = readConfig()
|
|
106
|
+
} catch (e) {
|
|
107
|
+
console.log('gatekeeper: could not parse config at boot:', e.message)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const admin = (req, res, next) => {
|
|
112
|
+
try {
|
|
113
|
+
if (app.securityhandler && app.securityhandler.isAdmin(req)) return next()
|
|
114
|
+
} catch {}
|
|
115
|
+
res.status(403).json({ error: 'gatekeeper requires an admin login' })
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Unauthenticated liveness probe. `started` changing across polls is the
|
|
119
|
+
// client's proof that the restart actually happened.
|
|
120
|
+
app.get('/plugin/gatekeeper/ping.json', (req, res) => {
|
|
121
|
+
res.json({ up: true, started: PROCESS_STARTED })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
app.get('/plugin/gatekeeper/status.json', admin, (req, res) => {
|
|
125
|
+
if (!configPath)
|
|
126
|
+
return res.json({
|
|
127
|
+
readOnly: true,
|
|
128
|
+
error: 'no --config path in argv; gatekeeper cannot manage this farm',
|
|
129
|
+
sites: [],
|
|
130
|
+
})
|
|
131
|
+
let config
|
|
132
|
+
try {
|
|
133
|
+
config = readConfig()
|
|
134
|
+
} catch (e) {
|
|
135
|
+
return res.status(500).json({ error: `config unreadable: ${e.message}` })
|
|
136
|
+
}
|
|
137
|
+
let configMtime = null
|
|
138
|
+
try {
|
|
139
|
+
configMtime = fs.statSync(configPath).mtimeMs
|
|
140
|
+
} catch {}
|
|
141
|
+
const domains = config.wikiDomains || {}
|
|
142
|
+
const bootDomains = (bootSnapshot && bootSnapshot.wikiDomains) || {}
|
|
143
|
+
const sites = Object.keys(domains).map(host => {
|
|
144
|
+
const site = domains[host] || {}
|
|
145
|
+
const pending =
|
|
146
|
+
JSON.stringify(site) !== JSON.stringify(bootDomains[host] || {})
|
|
147
|
+
return {
|
|
148
|
+
host,
|
|
149
|
+
restricted: site.restricted != null,
|
|
150
|
+
allowed_ids: site.allowed_ids || null,
|
|
151
|
+
allowed_domains: site.allowed_domains || null,
|
|
152
|
+
splash_page: site.splash_page || null,
|
|
153
|
+
details: site.details || null,
|
|
154
|
+
public_pages: site.public_pages || null,
|
|
155
|
+
require_terms: site.require_terms || null,
|
|
156
|
+
security_type: site.security_type || null,
|
|
157
|
+
pending,
|
|
158
|
+
warnings: lint(site),
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
// Sites removed from the file since boot are also a pending change.
|
|
162
|
+
for (const host of Object.keys(bootDomains))
|
|
163
|
+
if (!(host in domains))
|
|
164
|
+
sites.push({ host, removed: true, pending: true, warnings: [] })
|
|
165
|
+
res.json({
|
|
166
|
+
configPath,
|
|
167
|
+
farm: !!config.farm,
|
|
168
|
+
security_type: config.security_type || null,
|
|
169
|
+
processStarted: PROCESS_STARTED,
|
|
170
|
+
configMtime,
|
|
171
|
+
configEditedSinceBoot: configMtime != null && configMtime > PROCESS_STARTED,
|
|
172
|
+
supervised: supervised(),
|
|
173
|
+
supervisor: supervisor(),
|
|
174
|
+
sites,
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
app.post('/plugin/gatekeeper/toggle', admin, (req, res) => {
|
|
179
|
+
if (!configPath) return res.status(500).json({ error: 'no config path' })
|
|
180
|
+
const { host, restricted } = req.body || {}
|
|
181
|
+
if (!validHost(host)) return res.status(400).json({ error: 'bad host' })
|
|
182
|
+
writeConfig(config => {
|
|
183
|
+
config.wikiDomains = config.wikiDomains || {}
|
|
184
|
+
const site = (config.wikiDomains[host] = config.wikiDomains[host] || {})
|
|
185
|
+
if (restricted) {
|
|
186
|
+
site.restricted = 'true'
|
|
187
|
+
// Any logged-in user may view unless the admin narrows it later.
|
|
188
|
+
if (!site.allowed_ids && !site.allowed_domains) site.allowed_ids = ['*']
|
|
189
|
+
} else {
|
|
190
|
+
// Upstream checks mere presence of the key, so un-restrict = delete.
|
|
191
|
+
delete site.restricted
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
.then(() => res.json({ ok: true }))
|
|
195
|
+
.catch(e => res.status(500).json({ error: e.message }))
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
app.post('/plugin/gatekeeper/allowed', admin, (req, res) => {
|
|
199
|
+
if (!configPath) return res.status(500).json({ error: 'no config path' })
|
|
200
|
+
const body = req.body || {}
|
|
201
|
+
const { host } = body
|
|
202
|
+
if (!validHost(host)) return res.status(400).json({ error: 'bad host' })
|
|
203
|
+
for (const key of ['allowed_ids', 'allowed_domains'])
|
|
204
|
+
if (body[key] != null && (!Array.isArray(body[key]) || !body[key].every(validEntry)))
|
|
205
|
+
return res.status(400).json({ error: `${key} must be an array of comma-free strings` })
|
|
206
|
+
for (const key of ['splash_page', 'details', 'require_terms'])
|
|
207
|
+
if (body[key] != null && typeof body[key] !== 'string')
|
|
208
|
+
return res.status(400).json({ error: `${key} must be a string` })
|
|
209
|
+
if (body.public_pages != null &&
|
|
210
|
+
(!Array.isArray(body.public_pages) || !body.public_pages.every(validSlugName)))
|
|
211
|
+
return res.status(400).json({ error: 'public_pages must be an array of comma-free page names' })
|
|
212
|
+
writeConfig(config => {
|
|
213
|
+
config.wikiDomains = config.wikiDomains || {}
|
|
214
|
+
const site = (config.wikiDomains[host] = config.wikiDomains[host] || {})
|
|
215
|
+
for (const key of SITE_KEYS.filter(k => k !== 'restricted'))
|
|
216
|
+
if (key in body) {
|
|
217
|
+
const empty =
|
|
218
|
+
body[key] == null ||
|
|
219
|
+
(Array.isArray(body[key]) && body[key].length === 0) ||
|
|
220
|
+
(typeof body[key] === 'string' && body[key].trim() === '')
|
|
221
|
+
if (empty) delete site[key]
|
|
222
|
+
else site[key] = body[key]
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
.then(() => res.json({ ok: true }))
|
|
226
|
+
.catch(e => res.status(500).json({ error: e.message }))
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
app.post('/plugin/gatekeeper/site', admin, (req, res) => {
|
|
230
|
+
if (!configPath) return res.status(500).json({ error: 'no config path' })
|
|
231
|
+
const { host, action } = req.body || {}
|
|
232
|
+
if (!validHost(host)) return res.status(400).json({ error: 'bad host' })
|
|
233
|
+
if (action !== 'add' && action !== 'remove')
|
|
234
|
+
return res.status(400).json({ error: 'action must be add or remove' })
|
|
235
|
+
writeConfig(config => {
|
|
236
|
+
config.wikiDomains = config.wikiDomains || {}
|
|
237
|
+
if (action === 'add') config.wikiDomains[host] = config.wikiDomains[host] || {}
|
|
238
|
+
else delete config.wikiDomains[host]
|
|
239
|
+
})
|
|
240
|
+
.then(() => res.json({ ok: true }))
|
|
241
|
+
.catch(e => res.status(500).json({ error: e.message }))
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
app.post('/plugin/gatekeeper/restart', admin, (req, res) => {
|
|
245
|
+
if (!supervised() && !(req.body && req.body.force))
|
|
246
|
+
return res.status(409).json({
|
|
247
|
+
error: 'no supervisor detected — an exit would leave the farm down',
|
|
248
|
+
hint: 'restart from a shell with: wiki-start --restart (or POST again with {"force":true})',
|
|
249
|
+
})
|
|
250
|
+
console.log('gatekeeper: exit to restart')
|
|
251
|
+
res.sendStatus(200)
|
|
252
|
+
// Let the response flush before dying (plugmatic exits immediately and
|
|
253
|
+
// the 200 usually — but not always — escapes the process).
|
|
254
|
+
setTimeout(() => process.exit(0), 250)
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
module.exports = { startServer }
|
package/README.md
DELETED