wiki-plugin-farmmanager 0.1.1 → 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/README.md +14 -1
- package/client/farmmanager.js +29 -0
- package/client/farmmanager.js.map +7 -0
- package/factory.json +6 -0
- package/package.json +4 -1
- package/server/server.js +142 -4
package/README.md
CHANGED
|
@@ -35,8 +35,21 @@ Authentication for all endpoints will be handled by a middleware that checks `ap
|
|
|
35
35
|
- **`PATCH /plugin/farmmanager/sites/:domain`**
|
|
36
36
|
- **Action**: Partially update a site's properties (preserves unspecified fields).
|
|
37
37
|
- **Request Body**: `{ "owner": {"name": "Carol", ...} }` or `{ "status": "active" }` or both
|
|
38
|
+
- **Status values**: `active`, `readonly`, or `inactive` (see [Site states](#site-states)).
|
|
38
39
|
- **Response Body**: The updated site object.
|
|
39
40
|
|
|
41
|
+
### Site states
|
|
42
|
+
|
|
43
|
+
A site's `status` (stored in `<site>/status.json` and surfaced on every site object) is one of:
|
|
44
|
+
|
|
45
|
+
- **`active`** — normal, fully editable site.
|
|
46
|
+
- **`readonly`** — pages are still served and readable, but edits, forks, and new pages are blocked. Reversible: `PATCH` the status back to `active` to restore editing. The transition is idempotent — re-applying `readonly` keeps the original `readOnlyAt` timestamp, and returning to `active` clears it.
|
|
47
|
+
- **`inactive`** — soft-deleted / deactivated (set by `DELETE` without `hard=true`).
|
|
48
|
+
|
|
49
|
+
**How read-only is enforced (plugin-only):** wiki-server gates every content-mutating route — edit/add/remove/move/create/fork via `PUT /page/:slug/action`, page `DELETE`, favicon upload, and recycler deletes — behind a single `securityhandler.isAuthorized(req)` check, while public page reads never call it. On startup the plugin wraps that handler so it denies authorization whenever the current site's `status.json` reads `readonly`, blocking all writes without touching reads and without modifying wiki-server. `isAdmin` is left intact, so the farm-manager API can still flip the flag back. In farm mode each site has its own server instance; the flag is re-read from disk per check, so a status change made from the admin site takes effect immediately.
|
|
50
|
+
|
|
51
|
+
**On-site indicator (plugin-only):** the wiki client always loads `/theme/style.css` (served from `<site>/status/theme/style.css`). When a site goes read-only the plugin writes a small, clearly-delimited banner rule into that stylesheet, and removes only that block when the site is reactivated — any owner-authored theme CSS in the file is preserved. This needs no changes to wiki-client or the security module. Because CSS cannot read the per-request `isOwner` flag, the banner is shown to **every** visitor of a read-only site, not only the owner. (An owner-only on-site message would require client JS reading `isOwner`, which means touching the auth provider's client code; the banner above is the robust plugin-only option.)
|
|
52
|
+
|
|
40
53
|
- **`DELETE /plugin/farmmanager/sites/:domain`**
|
|
41
54
|
- **Action**: Deactivate a wiki site (soft delete). Adds a `status.json` file marking the site as inactive.
|
|
42
55
|
- **Query Parameters**:
|
|
@@ -45,7 +58,7 @@ Authentication for all endpoints will be handled by a middleware that checks `ap
|
|
|
45
58
|
|
|
46
59
|
## Web Interface Design
|
|
47
60
|
|
|
48
|
-
The
|
|
61
|
+
The client component (`farmmanager` plugin item) renders an admin dashboard inside a wiki page. It lists every site in the farm with its owner, page count, and a status badge (`active` / `read-only` / `inactive`), and gives each site a one-click action to flip it between editable and read-only (and to reactivate a deactivated site). It calls the JSON API above, so it inherits the same admin-only access control; non-admin or non-farm responses are surfaced as an inline message. Add it to a page like any other plugin item (a `farmmanager` story item), ideally on your designated admin site.
|
|
49
62
|
|
|
50
63
|
## Development Plan
|
|
51
64
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* wiki-plugin-farmmanager - 0.2.0 - Thu, 18 Jun 2026 19:08:45 GMT */
|
|
2
|
+
(()=>{var d=a=>a.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/\*(.+?)\*/g,"<i>$1</i>"),f={active:{label:"active",color:"#2e7d32"},readonly:{label:"read-only",color:"#b25900"},inactive:{label:"inactive",color:"#777777"}},p={active:{status:"readonly",label:"Make read-only"},readonly:{status:"active",label:"Make editable"},inactive:{status:"active",label:"Reactivate"}},u=a=>{let t=f[a]||{label:a,color:"#777777"};return`<span style="display:inline-block;background:${t.color};color:#fff;border-radius:3px;padding:1px 7px;font-size:11px;font-weight:600;">${t.label}</span>`},m=a=>a.length?a.map(t=>{let n=t.owner&&t.owner.name?t.owner.name:"Unknown",o=p[t.status]||p.active;return`
|
|
3
|
+
<tr>
|
|
4
|
+
<td style="padding:3px 8px;"><a href="//${d(t.name)}" target="_blank">${d(t.name)}</a></td>
|
|
5
|
+
<td style="padding:3px 8px;">${d(n)}</td>
|
|
6
|
+
<td style="padding:3px 8px;text-align:right;">${t.pages}</td>
|
|
7
|
+
<td style="padding:3px 8px;">${u(t.status)}</td>
|
|
8
|
+
<td style="padding:3px 8px;">
|
|
9
|
+
<button class="fm-toggle" data-domain="${d(t.name)}" data-status="${o.status}">${o.label}</button>
|
|
10
|
+
</td>
|
|
11
|
+
</tr>`}).join(""):'<tr><td colspan="5"><i>no sites found</i></td></tr>',c=(a,t)=>{let n=$(`
|
|
12
|
+
<div class="farmmanager" style="background:#eee;padding:12px;">
|
|
13
|
+
<div style="margin-bottom:8px;font-weight:600;">Farm Manager</div>
|
|
14
|
+
<div class="fm-message" style="color:#b00;margin-bottom:6px;"></div>
|
|
15
|
+
<table style="width:100%;border-collapse:collapse;font-size:13px;">
|
|
16
|
+
<thead>
|
|
17
|
+
<tr style="text-align:left;border-bottom:1px solid #ccc;">
|
|
18
|
+
<th style="padding:3px 8px;">Site</th>
|
|
19
|
+
<th style="padding:3px 8px;">Owner</th>
|
|
20
|
+
<th style="padding:3px 8px;text-align:right;">Pages</th>
|
|
21
|
+
<th style="padding:3px 8px;">Status</th>
|
|
22
|
+
<th style="padding:3px 8px;"></th>
|
|
23
|
+
</tr>
|
|
24
|
+
</thead>
|
|
25
|
+
<tbody class="fm-rows"><tr><td colspan="5"><i>loading\u2026</i></td></tr></tbody>
|
|
26
|
+
</table>
|
|
27
|
+
<div style="margin-top:8px;"><button class="fm-refresh">Refresh</button></div>
|
|
28
|
+
</div>`);a.empty().append(n);let o=e=>n.find(".fm-message").text(e||""),r=()=>{o(""),fetch("/plugin/farmmanager/sites").then(e=>e.ok?e.json():(e.status===403?o("Admin access required to manage the farm."):e.status===400?o("Server must be running in farm mode."):o(`Error ${e.status} ${e.statusText}`),null)).then(e=>{e&&(e.sort((s,l)=>s.name.localeCompare(l.name)),n.find(".fm-rows").html(m(e)))}).catch(()=>o("Failed to load sites."))};return n.on("click",".fm-refresh",r),n.on("dblclick","button, a",e=>e.stopPropagation()),n.on("click",".fm-toggle",function(){let e=$(this),s=e.attr("data-domain"),l=e.attr("data-status");e.prop("disabled",!0),fetch(`/plugin/farmmanager/sites/${encodeURIComponent(s)}`,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({status:l})}).then(i=>{if(!i.ok){o(`Error ${i.status} ${i.statusText}`),e.prop("disabled",!1);return}r()}).catch(()=>{o("Failed to update site."),e.prop("disabled",!1)})}),r(),n},g=(a,t)=>a.on("dblclick",()=>wiki.textEditor(a,t)),b=(a,t)=>{let n=a.parents(".page:first");c(a,t),g(a,t),wiki.pageHandler.put(n,{type:"edit",id:t.id,item:t})};typeof window<"u"&&(window.plugins.farmmanager={emit:c,bind:g,editor:b});var x=typeof window>"u"?{expand:d}:void 0;})();
|
|
29
|
+
//# sourceMappingURL=farmmanager.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/client/farmmanager.js"],
|
|
4
|
+
"sourcesContent": ["const expand = text => {\n return text\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\\*(.+?)\\*/g, '<i>$1</i>')\n}\n\n// Visual treatment for each site status.\nconst STATUS_BADGE = {\n active: { label: 'active', color: '#2e7d32' },\n readonly: { label: 'read-only', color: '#b25900' },\n inactive: { label: 'inactive', color: '#777777' },\n}\n\n// The status a site moves to when the admin clicks its action button, and the\n// label for that button. Read-only is the reversible flip we care about here.\nconst NEXT_ACTION = {\n active: { status: 'readonly', label: 'Make read-only' },\n readonly: { status: 'active', label: 'Make editable' },\n inactive: { status: 'active', label: 'Reactivate' },\n}\n\nconst badge = status => {\n const s = STATUS_BADGE[status] || { label: status, color: '#777777' }\n return `<span style=\"display:inline-block;background:${s.color};color:#fff;border-radius:3px;padding:1px 7px;font-size:11px;font-weight:600;\">${s.label}</span>`\n}\n\nconst rows = sites => {\n if (!sites.length) return '<tr><td colspan=\"5\"><i>no sites found</i></td></tr>'\n return sites\n .map(site => {\n const owner = site.owner && site.owner.name ? site.owner.name : 'Unknown'\n const next = NEXT_ACTION[site.status] || NEXT_ACTION.active\n return `\n <tr>\n <td style=\"padding:3px 8px;\"><a href=\"//${expand(site.name)}\" target=\"_blank\">${expand(site.name)}</a></td>\n <td style=\"padding:3px 8px;\">${expand(owner)}</td>\n <td style=\"padding:3px 8px;text-align:right;\">${site.pages}</td>\n <td style=\"padding:3px 8px;\">${badge(site.status)}</td>\n <td style=\"padding:3px 8px;\">\n <button class=\"fm-toggle\" data-domain=\"${expand(site.name)}\" data-status=\"${next.status}\">${next.label}</button>\n </td>\n </tr>`\n })\n .join('')\n}\n\nconst emit = ($item, item) => {\n const $root = $(`\n <div class=\"farmmanager\" style=\"background:#eee;padding:12px;\">\n <div style=\"margin-bottom:8px;font-weight:600;\">Farm Manager</div>\n <div class=\"fm-message\" style=\"color:#b00;margin-bottom:6px;\"></div>\n <table style=\"width:100%;border-collapse:collapse;font-size:13px;\">\n <thead>\n <tr style=\"text-align:left;border-bottom:1px solid #ccc;\">\n <th style=\"padding:3px 8px;\">Site</th>\n <th style=\"padding:3px 8px;\">Owner</th>\n <th style=\"padding:3px 8px;text-align:right;\">Pages</th>\n <th style=\"padding:3px 8px;\">Status</th>\n <th style=\"padding:3px 8px;\"></th>\n </tr>\n </thead>\n <tbody class=\"fm-rows\"><tr><td colspan=\"5\"><i>loading\u2026</i></td></tr></tbody>\n </table>\n <div style=\"margin-top:8px;\"><button class=\"fm-refresh\">Refresh</button></div>\n </div>`)\n $item.empty().append($root)\n\n const message = text => $root.find('.fm-message').text(text || '')\n\n const load = () => {\n message('')\n fetch('/plugin/farmmanager/sites')\n .then(res => {\n if (!res.ok) {\n if (res.status === 403) message('Admin access required to manage the farm.')\n else if (res.status === 400) message('Server must be running in farm mode.')\n else message(`Error ${res.status} ${res.statusText}`)\n return null\n }\n return res.json()\n })\n .then(sites => {\n if (!sites) return\n sites.sort((a, b) => a.name.localeCompare(b.name))\n $root.find('.fm-rows').html(rows(sites))\n })\n .catch(() => message('Failed to load sites.'))\n }\n\n $root.on('click', '.fm-refresh', load)\n $root.on('dblclick', 'button, a', e => e.stopPropagation())\n $root.on('click', '.fm-toggle', function () {\n const $btn = $(this)\n const domain = $btn.attr('data-domain')\n const status = $btn.attr('data-status')\n $btn.prop('disabled', true)\n fetch(`/plugin/farmmanager/sites/${encodeURIComponent(domain)}`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ status }),\n })\n .then(res => {\n if (!res.ok) {\n message(`Error ${res.status} ${res.statusText}`)\n $btn.prop('disabled', false)\n return\n }\n load()\n })\n .catch(() => {\n message('Failed to update site.')\n $btn.prop('disabled', false)\n })\n })\n\n load()\n return $root\n}\n\nconst bind = ($item, item) => {\n return $item.on('dblclick', () => wiki.textEditor($item, item))\n}\n\n// Farmmanager always renders the same dashboard; the item has no meaningful\n// text. Declaring \"editor\" in factory.json makes the factory hand a freshly\n// added block to this function instead of the core text editor \u2014 which would\n// delete the item on focus-out if its text were left empty (the reason an empty\n// block used to vanish unless you typed a placeholder). Here we just render and\n// persist the item, so you can drop in an empty Farmmanager block and it stays.\nconst editor = ($item, item) => {\n const $page = $item.parents('.page:first')\n emit($item, item)\n bind($item, item)\n wiki.pageHandler.put($page, { type: 'edit', id: item.id, item })\n}\n\nif (typeof window !== 'undefined') {\n window.plugins.farmmanager = { emit, bind, editor }\n}\n\nexport const farmmanager = typeof window == 'undefined' ? { expand } : undefined\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["expand", "text", "STATUS_BADGE", "NEXT_ACTION", "badge", "status", "s", "rows", "sites", "site", "owner", "next", "emit", "$item", "item", "$root", "message", "load", "res", "a", "b", "$btn", "domain", "bind", "editor", "$page", "farmmanager"]
|
|
7
|
+
}
|
package/factory.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-plugin-farmmanager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Federated Wiki - Farm Manager Plugin",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
"fedwiki"
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
|
+
"build": "node scripts/build-client.js",
|
|
20
|
+
"prepublishOnly": "npm run build",
|
|
21
|
+
"test": "node --test",
|
|
19
22
|
"prettier:format": "prettier --write './**/*.js'",
|
|
20
23
|
"prettier:check": "prettier --check ./**/*.js"
|
|
21
24
|
},
|
package/server/server.js
CHANGED
|
@@ -1,6 +1,85 @@
|
|
|
1
1
|
import fsp from 'node:fs/promises'
|
|
2
|
+
import fs from 'node:fs'
|
|
2
3
|
import path from 'node:path'
|
|
3
4
|
|
|
5
|
+
// Site lifecycle states the farm manager understands.
|
|
6
|
+
// active - normal, fully editable site
|
|
7
|
+
// readonly - pages are still served and readable, but edits, forks and new
|
|
8
|
+
// pages are blocked (reversible)
|
|
9
|
+
// inactive - soft-deleted / deactivated
|
|
10
|
+
const VALID_STATUSES = ['active', 'inactive', 'readonly']
|
|
11
|
+
|
|
12
|
+
// On-site read-only indicator.
|
|
13
|
+
//
|
|
14
|
+
// The wiki client always loads /theme/style.css, which the server serves from
|
|
15
|
+
// <site>/status/theme/style.css. By writing a small banner rule into that
|
|
16
|
+
// stylesheet we surface the read-only state to anyone viewing the site, using
|
|
17
|
+
// an existing extension point and without touching wiki-client. The rule lives
|
|
18
|
+
// in a clearly delimited, managed block so any owner-authored theme CSS in the
|
|
19
|
+
// same file is preserved; clearing read-only strips only our block.
|
|
20
|
+
//
|
|
21
|
+
// Note: CSS cannot read the per-request `isOwner` flag, so this banner is shown
|
|
22
|
+
// to every visitor, not only the owner.
|
|
23
|
+
const BANNER_START = '/* >>> wiki-plugin-farmmanager read-only banner (managed; do not edit) >>> */'
|
|
24
|
+
const BANNER_END = '/* <<< wiki-plugin-farmmanager read-only banner <<< */'
|
|
25
|
+
|
|
26
|
+
const BANNER_CSS = `${BANNER_START}
|
|
27
|
+
body::before {
|
|
28
|
+
content: '\\1F512 Read-only \\2014 the site owner cannot edit, fork, or create pages here. You can still read and fork these pages to your own wiki.';
|
|
29
|
+
display: block;
|
|
30
|
+
box-sizing: border-box;
|
|
31
|
+
width: 100%;
|
|
32
|
+
padding: 4px 12px;
|
|
33
|
+
background: #faf3e6;
|
|
34
|
+
color: #7a5a2e;
|
|
35
|
+
border-bottom: 1px solid #ead9bd;
|
|
36
|
+
text-align: center;
|
|
37
|
+
font-family: sans-serif;
|
|
38
|
+
font-size: 12px;
|
|
39
|
+
font-weight: 400;
|
|
40
|
+
}
|
|
41
|
+
${BANNER_END}`
|
|
42
|
+
|
|
43
|
+
// Remove the managed banner block (if present) from existing theme CSS,
|
|
44
|
+
// leaving any owner-authored rules untouched.
|
|
45
|
+
const stripBanner = function (css) {
|
|
46
|
+
const start = css.indexOf(BANNER_START)
|
|
47
|
+
if (start === -1) return css.trim()
|
|
48
|
+
const endMarker = css.indexOf(BANNER_END)
|
|
49
|
+
const end = endMarker === -1 ? css.length : endMarker + BANNER_END.length
|
|
50
|
+
return (css.slice(0, start) + css.slice(end)).replace(/\n{3,}/g, '\n\n').trim()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Reflect the read-only state in the site's theme stylesheet. Idempotent:
|
|
54
|
+
// re-applying produces the same file, and clearing removes only our block
|
|
55
|
+
// (deleting the file if nothing else remains).
|
|
56
|
+
const applyReadOnlyBanner = async function (siteDir, readOnly) {
|
|
57
|
+
const themeDir = path.join(siteDir, 'status', 'theme')
|
|
58
|
+
const themePath = path.join(themeDir, 'style.css')
|
|
59
|
+
|
|
60
|
+
let existing = ''
|
|
61
|
+
try {
|
|
62
|
+
existing = await fsp.readFile(themePath, 'utf8')
|
|
63
|
+
} catch (err) {
|
|
64
|
+
// No theme stylesheet yet; that's fine.
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const base = stripBanner(existing)
|
|
68
|
+
|
|
69
|
+
if (readOnly) {
|
|
70
|
+
const next = base ? `${base}\n\n${BANNER_CSS}\n` : `${BANNER_CSS}\n`
|
|
71
|
+
await fsp.mkdir(themeDir, { recursive: true })
|
|
72
|
+
await fsp.writeFile(themePath, next)
|
|
73
|
+
} else if (existing) {
|
|
74
|
+
if (base) {
|
|
75
|
+
await fsp.writeFile(themePath, `${base}\n`)
|
|
76
|
+
} else {
|
|
77
|
+
// The stylesheet held only our banner; remove it entirely.
|
|
78
|
+
await fsp.rm(themePath, { force: true })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
4
83
|
// Helper function to read owner information from a site directory
|
|
5
84
|
const readSiteOwner = async function (siteDir) {
|
|
6
85
|
try {
|
|
@@ -87,6 +166,51 @@ const startServer = async function (params) {
|
|
|
87
166
|
// We need to go up one level to get to the farm root
|
|
88
167
|
const dataDir = path.resolve(argv.data || './data', '..')
|
|
89
168
|
|
|
169
|
+
// --- Read-only enforcement -------------------------------------------------
|
|
170
|
+
// A site is "read-only" when its status.json says so. In that state pages are
|
|
171
|
+
// still served (public reads never consult isAuthorized) but every
|
|
172
|
+
// content-mutating route in wiki-server is gated behind
|
|
173
|
+
// securityhandler.isAuthorized(): edits/adds/removes/moves/creates/forks via
|
|
174
|
+
// PUT /page/:slug/action, page DELETE, favicon upload, and recycler deletes.
|
|
175
|
+
// By wrapping isAuthorized to deny while the current site is read-only we
|
|
176
|
+
// block every write without touching reads — and without modifying
|
|
177
|
+
// wiki-server itself. isAdmin is left untouched, so the farm-manager API can
|
|
178
|
+
// still flip the flag back.
|
|
179
|
+
//
|
|
180
|
+
// In farm mode each site gets its own app instance and its own plugin load,
|
|
181
|
+
// so argv.data is the current site's directory and its status.json lives at
|
|
182
|
+
// its root. We re-read it on each check (writes are infrequent) so a flag
|
|
183
|
+
// flipped from the admin site — a different app instance — takes effect
|
|
184
|
+
// immediately on the site being served.
|
|
185
|
+
const currentSiteStatusPath = path.join(argv.data || './data', 'status.json')
|
|
186
|
+
|
|
187
|
+
const isCurrentSiteReadOnly = function () {
|
|
188
|
+
try {
|
|
189
|
+
const raw = fs.readFileSync(currentSiteStatusPath, 'utf8')
|
|
190
|
+
return JSON.parse(raw).status === 'readonly'
|
|
191
|
+
} catch (err) {
|
|
192
|
+
// No status.json, unreadable, or malformed: treat the site as writable.
|
|
193
|
+
return false
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const securityhandler = app.securityhandler
|
|
198
|
+
if (
|
|
199
|
+
securityhandler &&
|
|
200
|
+
typeof securityhandler.isAuthorized === 'function' &&
|
|
201
|
+
!securityhandler.farmmanagerReadOnlyWrapped
|
|
202
|
+
) {
|
|
203
|
+
const originalIsAuthorized = securityhandler.isAuthorized.bind(securityhandler)
|
|
204
|
+
securityhandler.isAuthorized = function (req) {
|
|
205
|
+
if (isCurrentSiteReadOnly()) {
|
|
206
|
+
return false
|
|
207
|
+
}
|
|
208
|
+
return originalIsAuthorized(req)
|
|
209
|
+
}
|
|
210
|
+
// Guard against double-wrapping if startServer ever runs twice on one app.
|
|
211
|
+
securityhandler.farmmanagerReadOnlyWrapped = true
|
|
212
|
+
}
|
|
213
|
+
|
|
90
214
|
// Middleware to ensure only admin users can access these endpoints
|
|
91
215
|
const admin = function (req, res, next) {
|
|
92
216
|
if (req.app.securityhandler && req.app.securityhandler.isAdmin(req)) {
|
|
@@ -215,8 +339,8 @@ const startServer = async function (params) {
|
|
|
215
339
|
|
|
216
340
|
// Update status if specified
|
|
217
341
|
if (status) {
|
|
218
|
-
if (status
|
|
219
|
-
return res.status(400).json({ error:
|
|
342
|
+
if (!VALID_STATUSES.includes(status)) {
|
|
343
|
+
return res.status(400).json({ error: `Status must be one of: ${VALID_STATUSES.join(', ')}` })
|
|
220
344
|
}
|
|
221
345
|
|
|
222
346
|
let statusData = {}
|
|
@@ -228,14 +352,25 @@ const startServer = async function (params) {
|
|
|
228
352
|
}
|
|
229
353
|
|
|
230
354
|
statusData.status = status
|
|
355
|
+
// Maintain transition timestamps idempotently: re-applying the same
|
|
356
|
+
// status keeps its original timestamp, and markers belonging to other
|
|
357
|
+
// states are cleared so status.json always reflects exactly one state.
|
|
231
358
|
if (status === 'inactive') {
|
|
232
|
-
statusData.deactivatedAt
|
|
359
|
+
statusData.deactivatedAt ||= new Date().toISOString()
|
|
360
|
+
delete statusData.readOnlyAt
|
|
361
|
+
} else if (status === 'readonly') {
|
|
362
|
+
statusData.readOnlyAt ||= new Date().toISOString()
|
|
363
|
+
delete statusData.deactivatedAt
|
|
233
364
|
} else {
|
|
234
|
-
//
|
|
365
|
+
// active: clear any soft-delete / read-only markers
|
|
235
366
|
delete statusData.deactivatedAt
|
|
367
|
+
delete statusData.readOnlyAt
|
|
236
368
|
}
|
|
237
369
|
|
|
238
370
|
await fsp.writeFile(statusPath, JSON.stringify(statusData, null, 2))
|
|
371
|
+
|
|
372
|
+
// Keep the on-site read-only banner in sync with the new status.
|
|
373
|
+
await applyReadOnlyBanner(siteDir, status === 'readonly')
|
|
239
374
|
}
|
|
240
375
|
|
|
241
376
|
// Return updated site info
|
|
@@ -278,6 +413,9 @@ const startServer = async function (params) {
|
|
|
278
413
|
}
|
|
279
414
|
await fsp.writeFile(statusPath, JSON.stringify(statusData, null, 2))
|
|
280
415
|
|
|
416
|
+
// Deactivation supersedes read-only; clear any read-only banner.
|
|
417
|
+
await applyReadOnlyBanner(siteDir, false)
|
|
418
|
+
|
|
281
419
|
res.json({
|
|
282
420
|
status: 'ok',
|
|
283
421
|
message: `Site ${domain} deactivated.`,
|