wiki-plugin-farmmanager 0.1.0 → 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 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 web interface will be developed after the backend API is complete. It will follow the established FedWiki pattern of providing a client-side component that can be embedded and used within a wiki page.
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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").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, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\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
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "Farmmanager",
3
+ "title": "Farm Manager — read-only & site admin dashboard",
4
+ "category": "other",
5
+ "editor": true
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-plugin-farmmanager",
3
- "version": "0.1.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,15 +1,97 @@
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 {
7
86
  const ownerPath = path.join(siteDir, 'status', 'owner.json')
8
87
  const ownerData = await fsp.readFile(ownerPath, 'utf8')
9
88
  const owner = JSON.parse(ownerData)
10
- return owner.name || 'Unknown'
89
+ return {
90
+ ...owner,
91
+ name: owner.name || 'Unknown',
92
+ }
11
93
  } catch (err) {
12
- return 'Unknown'
94
+ return { name: 'Unknown' }
13
95
  }
14
96
  }
15
97
 
@@ -84,6 +166,51 @@ const startServer = async function (params) {
84
166
  // We need to go up one level to get to the farm root
85
167
  const dataDir = path.resolve(argv.data || './data', '..')
86
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
+
87
214
  // Middleware to ensure only admin users can access these endpoints
88
215
  const admin = function (req, res, next) {
89
216
  if (req.app.securityhandler && req.app.securityhandler.isAdmin(req)) {
@@ -212,8 +339,8 @@ const startServer = async function (params) {
212
339
 
213
340
  // Update status if specified
214
341
  if (status) {
215
- if (status !== 'active' && status !== 'inactive') {
216
- return res.status(400).json({ error: 'Status must be either "active" or "inactive"' })
342
+ if (!VALID_STATUSES.includes(status)) {
343
+ return res.status(400).json({ error: `Status must be one of: ${VALID_STATUSES.join(', ')}` })
217
344
  }
218
345
 
219
346
  let statusData = {}
@@ -225,14 +352,25 @@ const startServer = async function (params) {
225
352
  }
226
353
 
227
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.
228
358
  if (status === 'inactive') {
229
- statusData.deactivatedAt = new Date().toISOString()
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
230
364
  } else {
231
- // If re-activating, remove the deactivation timestamp
365
+ // active: clear any soft-delete / read-only markers
232
366
  delete statusData.deactivatedAt
367
+ delete statusData.readOnlyAt
233
368
  }
234
369
 
235
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')
236
374
  }
237
375
 
238
376
  // Return updated site info
@@ -275,6 +413,9 @@ const startServer = async function (params) {
275
413
  }
276
414
  await fsp.writeFile(statusPath, JSON.stringify(statusData, null, 2))
277
415
 
416
+ // Deactivation supersedes read-only; clear any read-only banner.
417
+ await applyReadOnlyBanner(siteDir, false)
418
+
278
419
  res.json({
279
420
  status: 'ok',
280
421
  message: `Site ${domain} deactivated.`,