mdshelf 1.0.8 → 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 +302 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -2
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
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"hasInstallScript": true,
|
|
24
24
|
"license": "MIT OR Apache-2.0",
|
|
25
25
|
"name": "mdshelf",
|
|
26
|
-
"version": "1.0
|
|
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
|
|
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
|
|
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
|
|
103
|
+
"version": "1.1.0",
|
|
104
104
|
"volta": {
|
|
105
105
|
"node": "18.14.1",
|
|
106
106
|
"npm": "9.5.0"
|