mdshelf 1.0.7 → 1.1.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
@@ -24,6 +24,7 @@ Ships as a single static Rust binary with zero runtime dependencies.
24
24
  - Prev / next page navigation
25
25
  - Auto-generated index pages for folders that have no `index.md`
26
26
  - Home page listing all mounted sites with page counts
27
+ - Copy or download any page's Markdown source straight from its header
27
28
 
28
29
  **Theme**
29
30
  - Bundled `mdshelf-theme` — mobile-first, responsive, light/dark aware
@@ -115,6 +116,14 @@ Open `http://127.0.0.1:4444/` for the site index.
115
116
  | `mdshelf serve` | Start the web server |
116
117
  | `mdshelf check` | Validate config and scan content |
117
118
  | `mdshelf export` | Export a static bundle of HTML and CSS files |
119
+ | `mdshelf auth setup` | Walk through creating a Google OAuth client, then verify it |
120
+ | `mdshelf acl explain <path> <email>` | Show why an address can or cannot read a page |
121
+ | `mdshelf acl doctor` | Report access-rule problems |
122
+ | `mdshelf acl grant <email> <path>` | Add an address to a page or folder's `allow` list |
123
+ | `mdshelf audit --path/--email` | Query the access log |
124
+ | `mdshelf share <path>` | Create a link that shares one page with anyone holding the URL |
125
+ | `mdshelf share list` | List share links (`--all` for dead ones, `--json` for an inventory) |
126
+ | `mdshelf share revoke <id>` | Revoke one link, or `--all` to revoke every live link |
118
127
  | `mdshelf install` | Register as a native system service |
119
128
  | `mdshelf start` | Start the installed service |
120
129
  | `mdshelf stop` | Stop the installed service |
@@ -171,14 +180,276 @@ draft: false
171
180
  ---
172
181
  ```
173
182
 
183
+ Two further keys, `allow` and `deny`, control who may read a page — see
184
+ [Private sites with Google sign-in](#private-sites-with-google-sign-in). They are
185
+ stripped before rendering, so the addresses in them never reach the browser.
186
+
187
+ ## Private sites with Google sign-in
188
+
189
+ `mdshelf serve --auth google` puts every page behind Google sign-in and serves each
190
+ reader only what they have been invited to. Without the flag nothing changes: the
191
+ server behaves exactly as it always has.
192
+
193
+ ### Who may read what
194
+
195
+ Access rules live in the vault, as frontmatter, in the file they protect:
196
+
197
+ ```yaml
198
+ ---
199
+ title: Compensation 2026
200
+ allow:
201
+ - ana@corp.com
202
+ - hr-lead@corp.com
203
+ deny:
204
+ - intern@corp.com
205
+ ---
206
+ ```
207
+
208
+ Because the rule is part of the file, renaming, moving, or deleting it carries or
209
+ removes the rule with it. There is nothing to keep in sync.
210
+
211
+ Rules exist at three levels:
212
+
213
+ | Where | Governs |
214
+ |---|---|
215
+ | Any `.md` file | That file alone |
216
+ | A folder's `index.md` | **That folder, everything beneath it, and the index page itself** |
217
+ | The vault root's `index.md` | The whole site |
218
+
219
+ Both keys take a **list**. A bare string is an error, not a one-element list — so the
220
+ common typo `allow: a@x.com, b@y.com` is rejected rather than quietly granting access
221
+ to one malformed address.
222
+
223
+ Only `index.md` governs a folder. `readme.md` and `index.markdown` are ordinary pages to
224
+ mdshelf — served at `/readme` and `/index.markdown` — so a rule in one of them applies to
225
+ that page alone, not to everything beside it.
226
+
227
+ ### How a decision is reached
228
+
229
+ 1. Collect every rule that applies: the file's own, then each ancestor folder's
230
+ `index.md` from nearest to furthest, then the site root.
231
+ 2. The most specific level that **names the address** decides. A `deny` there beats an
232
+ inherited `allow`, and an `allow` there beats an inherited `deny`.
233
+ 3. If no level names the address, the answer is **deny**.
234
+
235
+ > **A site-level `allow` reaches into every folder.** A folder's `allow` *adds* people;
236
+ > it does not remove anyone. To keep somebody out of a subtree that a broader rule
237
+ > already grants, name them in that folder's `deny`. `mdshelf acl doctor` will not
238
+ > guess this for you — use `mdshelf acl explain <path> <email>` whenever the outcome
239
+ > surprises you.
240
+
241
+ ### Fail closed
242
+
243
+ A path that no rule names is invisible. **A vault with no rules at all shows nobody
244
+ anything** when `--auth google` is on — start by granting someone in the root
245
+ `index.md`. Likewise, a malformed `allow` or `deny` block makes that file unreadable by
246
+ everyone until it is fixed; `mdshelf check` fails on such a block so it never reaches a
247
+ running server, and `mdshelf acl doctor` reports it on a server already running.
248
+
249
+ Readers who are signed in but not invited get the same response for a restricted page
250
+ and for one that does not exist, so the site cannot be probed to learn what it contains.
251
+
252
+ ### Setting up Google credentials
253
+
254
+ mdshelf uses **your** Google OAuth client; no credentials ship with the binary.
255
+
256
+ ```bash
257
+ mdshelf auth setup
258
+ ```
259
+
260
+ The wizard prints the console steps, gives you the exact redirect URI to register,
261
+ stores the credentials in `~/.config/mdshelf/credentials.env` (mode 0600), and proves
262
+ with a real sign-in before it exits. `MDSHELF_GOOGLE_CLIENT_ID` and
263
+ `MDSHELF_GOOGLE_CLIENT_SECRET` override the file if you use a secret manager.
264
+
265
+ ### HTTPS
266
+
267
+ Google accepts a plain-HTTP redirect URI only for `localhost`, so any real deployment
268
+ needs a certificate. mdshelf refuses to serve authenticated sessions over plain HTTP on
269
+ a non-loopback address rather than warning and doing it anyway.
270
+
271
+ ```bash
272
+ # Let's Encrypt, obtained and renewed automatically (needs ports 80 and 443)
273
+ mdshelf serve --auth google --domain docs.acme.com
274
+
275
+ # A certificate you already have
276
+ mdshelf serve --auth google --tls-cert fullchain.pem --tls-key privkey.pem
277
+
278
+ # TLS terminated by an ALB, nginx, Caddy, or Cloudflare in front
279
+ mdshelf serve --auth google --behind-proxy --public-url https://docs.acme.com
280
+ ```
281
+
282
+ For LAN or offline testing, `mdshelf auth setup --self-signed <dir>` writes a
283
+ development certificate. Browsers will warn about it, and Google will not accept a
284
+ self-signed host as a redirect URI, so it is only useful before auth is switched on.
285
+
286
+ ### Sessions, revocation, and Google availability
287
+
288
+ Removing an address from a vault file takes effect on the **next request** — the
289
+ watcher reloads the rules and every request resolves them afresh.
290
+
291
+ Sessions are re-validated against Google when they have been idle for more than 30
292
+ minutes, so a suspended Google account loses access without a reader being interrupted
293
+ mid-page. Sessions expire absolutely after `session_max_age` (default 30 days).
294
+
295
+ > **Google is a hard dependency for re-validation.** If a re-validation attempt fails —
296
+ > whether Google explicitly rejects the grant *or* is simply unreachable — the session
297
+ > ends and the reader signs in again. A Google incident, or a self-hosted box that
298
+ > briefly loses its uplink, will therefore sign out readers whose sessions have gone
299
+ > idle. This is deliberate: it is what makes "revoked means revoked" true without
300
+ > qualification. Each such event is logged at WARN with the cause, so an outage is
301
+ > visible in the logs rather than presenting as mysterious sign-outs.
302
+
303
+ ### Static export
304
+
305
+ A static bundle has no authentication, so exporting a vault that declares rules
306
+ requires saying whose view it is:
307
+
308
+ ```bash
309
+ mdshelf export --as ana@corp.com --output ./for-ana
310
+ ```
311
+
312
+ The bundle then contains exactly the pages, navigation, listings, and attachments that
313
+ address can see, and reports how many pages were skipped. `mdshelf export` on a vault
314
+ with no rules is unchanged. Note that the bundle itself is unprotected once you send it.
315
+
316
+ ### Shareable links
317
+
318
+ A **Share** control appears on every page a signed-in viewer can read. Pick how long the
319
+ link should live and you get an opaque URL:
320
+
321
+ ```
322
+ https://docs.acme.com/s/kJ8mQ2xR7pL4nT9v
323
+ ```
324
+
325
+ Anyone holding that URL reads **that one page** — no account, no sign-in — in a clean
326
+ reading view, until it expires, until you revoke it, or until your own access to the
327
+ page goes away. The URL reveals nothing to anyone who merely sees it: not the site, not
328
+ the folder, not the page title, not that the page exists.
329
+
330
+ Share links require `--auth google`. Without it every page is already public, so a link
331
+ would imply a protection that is not there.
332
+
333
+ The same thing from a terminal:
334
+
335
+ ```bash
336
+ # Create. Prints exactly one URL, and only once.
337
+ mdshelf share /docs/hr/comp --for 1d --as ana@corp.com
338
+
339
+ # Or until a specific instant. Read as UTC unless it carries an offset.
340
+ mdshelf share /docs/hr/comp --until 2026-09-01
341
+
342
+ # What am I exposing?
343
+ mdshelf share list
344
+ mdshelf share list --all --json # inventory, including dead links
345
+
346
+ # The incident lever.
347
+ mdshelf share revoke ab12cd
348
+ mdshelf share revoke --all
349
+ ```
350
+
351
+ `--for` and `--until` are alternatives, not a pair. Without either, `[links]
352
+ default_lifetime` applies. `--base-url` supplies the origin browsers use to reach the
353
+ server when the config binds an address nobody can visit. Signed-in viewers see their
354
+ own links on `/__shares`.
355
+
356
+ **The URL is shown once and cannot be recovered.** mdshelf stores only `sha256(token)`,
357
+ so nothing — not the database, not `share list`, not a support request — can produce a
358
+ lost URL again. Create another instead.
359
+
360
+ #### What a share link is not
361
+
362
+ - **Not a folder or site link.** One link reaches one page plus the images and files
363
+ that page references. Share several pages by making several links, or use
364
+ `mdshelf export --as`.
365
+ - **Not password-protected.** The URL is the whole credential.
366
+ - **Not a second copy of your access rules.** A link is a *window onto its issuer's own
367
+ live access*, revalidated on every request.
368
+ - **Not recoverable, and not extendable.** To change an expiry, revoke and re-mint.
369
+ - **Not analytics.** Reads land in the access log; querying it is `mdshelf audit`.
370
+
371
+ #### Link configuration
372
+
373
+ ```toml
374
+ [links]
375
+ enabled = true # incident kill switch; leaves the rows intact
376
+ prefix = "/s" # the route links live under
377
+ default_lifetime = "1d" # used when neither --for nor --until is given
378
+ max_lifetime = "30d" # the cap; a longer request is refused, not clamped
379
+ revoked_retention = "90d" # how long dead rows are kept before the sweep deletes them
380
+
381
+ [auth]
382
+ bad_link_retention = "7d" # how long requests with unknown tokens are kept
383
+ ```
384
+
385
+ A site mounted at the same place as `prefix` fails startup and `mdshelf check`: move the
386
+ share route rather than the site.
387
+
388
+ #### Security note
389
+
390
+ > **A share link is a bearer credential.** Whoever holds the URL has the access —
391
+ > forwarding the URL forwards the access, and mdshelf cannot tell the difference. Four
392
+ > consequences worth stating plainly:
393
+ >
394
+ > - **The audit names a link, not a person.** A read appears as `link:ab12cd`, because
395
+ > that is the honest answer to "who read this?" for a URL that anyone may have passed
396
+ > on. `mdshelf share list` prints the same id, so the two join by eye.
397
+ > - **The banner exposes the sharer's address.** The reading view says "Shared by
398
+ > ana@corp.com", and that reaches everyone the URL reaches, forwarded recipients
399
+ > included. It is your own address, never a third party's.
400
+ > - **A link tracks its issuer's live access.** Remove someone from an allow list and
401
+ > every link they made stops working on the next request — so offboarding needs no
402
+ > separate cleanup. The same mechanism can break a working link with no signal to
403
+ > either side when somebody changes teams; `share list` shows the link as dead so the
404
+ > sharer can see why.
405
+ > - **Link-preview bots fetch URLs.** A bearer URL posted in a chat channel may be
406
+ > fetched and rendered into that channel. Responses carry `Cache-Control: no-store`
407
+ > and `X-Robots-Tag: noindex, nofollow`, which limits what persists, but the fetch
408
+ > itself is inherent to bearer links.
409
+
410
+ ### Auth-related configuration
411
+
412
+ ```toml
413
+ [auth]
414
+ session_max_age = "30d" # absolute session ceiling
415
+ owner_email = "owner@corp.com" # request-access link on the deny page
416
+ audit_retention = "90d" # access-log retention, pruned hourly
417
+ bad_link_retention = "7d" # requests with unknown share tokens
418
+ # database = "./mdshelf.db" # sessions, access log, and share links
419
+ # key_file = "~/.config/mdshelf/secret.key" # encrypts refresh tokens, mode 0600
420
+ ```
421
+
422
+ **`mdshelf.db` is not disposable.** Sessions, the access log and the derived rule index
423
+ can all be rebuilt or lived without, but share links cannot: only their hashes are
424
+ stored, so deleting the database destroys every live link with no way to recreate one.
425
+ Keep `mdshelf share list --json` if you want an inventory of what a deletion would cost.
426
+
427
+ ### What mdshelf writes
428
+
429
+ `mdshelf acl grant` is the **only** command that writes to your vault, and it asks
430
+ before creating a folder's `index.md`. Serving, checking, exporting, and explaining are
431
+ strictly read-only.
432
+
174
433
  ## Theme customisation
175
434
 
176
435
  Override or extend templates by pointing `[theme].directory` at a local directory:
177
436
 
178
- - `layouts/` — `base.html`, `doc.html`, `home.html`, `error.html`
437
+ - `layouts/` — `base.html`, `doc.html`, `home.html`, `error.html`, `link.html`
179
438
  - `partials/` — `header.html`, `sidebar.html`, `toc.html`, `breadcrumbs.html`, `footer.html`
180
439
  - `assets/` — static files served from `/__assets/...`
181
440
 
441
+ ### Theme capabilities
442
+
443
+ Two parts of the theme are optional in a way worth knowing about:
444
+
445
+ - **`{{ share_control }}`** renders the Share popover. The bundled theme places it in
446
+ `partials/header.html`. A theme that never mentions the variable renders normally and
447
+ simply has no sharing — mdshelf never injects it into your markup, so removing that
448
+ line is a supported way to turn the button off for a site. The CLI still works.
449
+ - **`layouts/link.html`** is the reading view a share-link recipient sees. A theme
450
+ without it falls back to a self-contained page that still picks up the theme's
451
+ stylesheets, so recipients never see a template error.
452
+
182
453
  Syntax highlighting CSS is served from `/__mdshelf/syntax.css` (class-based, light/dark aware).
183
454
 
184
455
  ## System service
@@ -255,6 +526,36 @@ The site becomes accessible at the same `*.ts.net` URL from anywhere, without an
255
526
  tailscale funnel off
256
527
  ```
257
528
 
529
+ ## Copy and download a page as Markdown
530
+
531
+ Every page mdshelf serves is a `.md` file, and the page header carries a **Page actions**
532
+ button that hands you that file:
533
+
534
+ - **Copy as Markdown** — puts the source on the clipboard.
535
+ - **Download Markdown** — saves it under its original vault filename, e.g. `setup.md`,
536
+ so it round-trips straight back into Obsidian and sibling `.md` links keep resolving.
537
+
538
+ Both actions use the source already embedded in the page you are reading, so they are
539
+ instant, work offline, and work inside an exported bundle opened from `file://`.
540
+
541
+ **Frontmatter is not included.** The copied and downloaded Markdown is the body only — no
542
+ YAML block, and therefore no `allow` or `deny` list. If the body does not already open
543
+ with an `# H1`, the page title is prepended, so a pasted document always has one. Line
544
+ endings are normalized to LF with exactly one trailing newline.
545
+
546
+ > **Clipboard access is blocked over plain HTTP.** Browsers expose `navigator.clipboard`
547
+ > only in a secure context — HTTPS, or `localhost` / `127.0.0.1`. Over a plain `http://`
548
+ > LAN or tailnet address it is simply absent, so mdshelf falls back to a legacy copy, and
549
+ > if that is refused too it falls back to manual selection: a **Select and copy** panel
550
+ > opens with the text already selected, so Ctrl/Cmd-C always works. Serving over HTTPS
551
+ > removes the fallback entirely.
552
+
553
+ A generated folder-index page has no `.md` file behind it, so both actions appear
554
+ disabled with the reason. Downloads normally happen entirely in the browser; iOS and
555
+ iPadOS Safari mishandle that, so there they go through `GET /__mdshelf/md/<page>`, which
556
+ enforces exactly the same access rules as viewing the page — a page you may not read
557
+ returns the same response as one that does not exist.
558
+
258
559
  ### Static export
259
560
 
260
561
  ```bash
@@ -23,7 +23,7 @@
23
23
  "hasInstallScript": true,
24
24
  "license": "MIT OR Apache-2.0",
25
25
  "name": "mdshelf",
26
- "version": "1.0.7"
26
+ "version": "1.1.0"
27
27
  },
28
28
  "node_modules/@isaacs/cliui": {
29
29
  "engines": {
@@ -542,5 +542,5 @@
542
542
  }
543
543
  },
544
544
  "requires": true,
545
- "version": "1.0.7"
545
+ "version": "1.1.0"
546
546
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "artifactDownloadUrls": [
3
- "https://github.com/prbski/mdshelf/releases/download/v1.0.7"
3
+ "https://github.com/prbski/mdshelf/releases/download/v1.1.0"
4
4
  ],
5
5
  "author": "Patryk Porebski",
6
6
  "bin": {
@@ -100,7 +100,7 @@
100
100
  "zipExt": ".tar.xz"
101
101
  }
102
102
  },
103
- "version": "1.0.7",
103
+ "version": "1.1.0",
104
104
  "volta": {
105
105
  "node": "18.14.1",
106
106
  "npm": "9.5.0"