|
| 1 | +# backup — download a pod container as a .tar.gz |
| 2 | + |
| 3 | +Data portability in one request: `GET /backup/<container>/` walks the |
| 4 | +container recursively and streams everything the caller can read as a |
| 5 | +gzipped POSIX ustar archive. Read-only, no external fetches, no writes — |
| 6 | +built on the [#206 plugin loader](https://jss.live/docs/features/plugins) |
| 7 | +with the loopback container-walk that `rss/`, `sparql/`, `search/` and |
| 8 | +`webdav/` established. |
| 9 | + |
| 10 | +```js |
| 11 | +plugins: [{ |
| 12 | + module: 'backup/plugin.js', |
| 13 | + prefix: '/backup', |
| 14 | + config: { |
| 15 | + loopbackUrl: 'http://127.0.0.1:3000', // where the plugin reaches its own host (required) |
| 16 | + // maxResources: 10000, // loopback fetches per archive (walk cap) |
| 17 | + // maxDepth: 32, // container nesting cap |
| 18 | + }, |
| 19 | +}] |
| 20 | +``` |
| 21 | + |
| 22 | +```sh |
| 23 | +# Everything under /alice/ you can read, as alice.tar.gz: |
| 24 | +curl -H "Authorization: Bearer $TOKEN" -OJ http://localhost:3000/backup/alice/ |
| 25 | + |
| 26 | +# A sub-container: |
| 27 | +curl -H "Authorization: Bearer $TOKEN" -OJ http://localhost:3000/backup/alice/photos/ |
| 28 | + |
| 29 | +# Restore is just tar: |
| 30 | +tar -xzf alice.tar.gz |
| 31 | +``` |
| 32 | + |
| 33 | +## Routes |
| 34 | + |
| 35 | +| Route | Returns | |
| 36 | +|---|---| |
| 37 | +| `GET /backup/<pod-path>/` | `application/gzip` tar of the container tree, `Content-Disposition: attachment` | |
| 38 | +| `GET /backup/<pod-path>` | same — a missing trailing slash is treated as the container | |
| 39 | +| `GET /backup/`, `GET /backup` | `400` with usage — whole-server export is deliberately not offered | |
| 40 | + |
| 41 | +An unreadable or missing **root** container is the caller's error status |
| 42 | +(`401`/`403`/`404`), decided *before* the `200` is committed. Everything |
| 43 | +below the root is best-effort: a member the caller cannot GET is **skipped, |
| 44 | +not fatal**. |
| 45 | + |
| 46 | +## What's in the archive |
| 47 | + |
| 48 | +- A **directory entry** (typeflag `5`) per container, a **file entry** per |
| 49 | + readable resource, byte-for-byte as the pod serves it, `mtime` from the |
| 50 | + listing's `dcterms:modified` when present. |
| 51 | +- **`MANIFEST.json` at the archive root** (written last, once the walk is |
| 52 | + complete): the requested container, generation time, `included` (path, |
| 53 | + entry name, size, content-type), `skipped` (path + reason |
| 54 | + `unauthorized` / `error` / `name-too-long` / `max-depth`, plus HTTP status |
| 55 | + where there was one), counts, and a `truncated` flag if `maxResources` / |
| 56 | + `maxDepth` cut the walk short. |
| 57 | +- Names longer than tar's 100-byte field ride the ustar **prefix field** |
| 58 | + (155 more bytes, split at a `/`); a name that still doesn't fit is skipped |
| 59 | + and counted. Percent-encoded segments are decoded for readability unless |
| 60 | + decoding would change the path shape (`.`, `..`, a `/` or NUL) — a hostile |
| 61 | + resource name cannot traverse out of the extraction directory. |
| 62 | +- `.acl` / `.meta` companions are **not** exported (see Findings). |
| 63 | + |
| 64 | +## Auth |
| 65 | + |
| 66 | +The walk happens over loopback HTTP **forwarding the caller's |
| 67 | +`Authorization` header verbatim**, so the host's real WAC decides every |
| 68 | +single GET. The plugin has no authority of its own: |
| 69 | + |
| 70 | +- with a pod bearer you get everything that token can read; |
| 71 | +- **with no credentials the request still works** — you get an archive of |
| 72 | + exactly the publicly-readable subset, and every private resource shows up |
| 73 | + in `MANIFEST.json` as `{ "reason": "unauthorized" }`. |
| 74 | + |
| 75 | +## What maps / what doesn't |
| 76 | + |
| 77 | +| Solid | tar | note | |
| 78 | +|---|---|---| |
| 79 | +| container | directory entry | typeflag `5`, mode 755 | |
| 80 | +| resource bytes + `Content-Type` | file entry + manifest `contentType` | tar has no MIME field; the manifest carries it | |
| 81 | +| `dcterms:modified` | entry `mtime` | best-effort from the container listing | |
| 82 | +| WAC (`.acl`) | — | not exported; permissions don't round-trip | |
| 83 | +| resource metadata (`.meta`) | — | not exported | |
| 84 | +| a consistent snapshot | — | the walk is not atomic; see Findings | |
| 85 | + |
| 86 | +## Findings |
| 87 | + |
| 88 | +- **No `api.events` → pull-on-demand only.** There is no |
| 89 | + `onResourceChange` hook, so incremental backup ("archive what changed |
| 90 | + since last time") and scheduled/server-initiated backup are impossible to |
| 91 | + build well: the plugin can't observe writes, and it has no credentials of |
| 92 | + its own to walk with anyway. Every backup is a full read-time crawl, |
| 93 | + initiated and authorized by the caller. Same wall as `sparql/`, `rss/`, |
| 94 | + `search/`, `matrix/` — the #2 seam in NOTES.md, now with a |
| 95 | + backup-flavoured consumer. |
| 96 | + |
| 97 | +- **No `api.authorize` / no direct read API → O(N) loopback round-trips.** |
| 98 | + Confirmed in the shape of the code: one HTTP GET per container listing |
| 99 | + plus one HTTP GET per resource, each carrying the requester's own |
| 100 | + credentials so WAC is answered correctly by construction. For a |
| 101 | + data-exfiltration-safe *export* feature this is definitionally right — |
| 102 | + but it means an N-resource pod costs N+containers loopback requests where |
| 103 | + a host-side seam (an authorized read API, or `api.authorize` + direct |
| 104 | + storage access) would make this a filesystem walk with one WAC check per |
| 105 | + entry. The dominant cost of a backup is HTTP overhead to yourself. |
| 106 | + |
| 107 | +- **Memory profile: streamed archive, one buffered resource at a time — |
| 108 | + and the buffering is structural.** The gzip stream goes to the client as |
| 109 | + the walk runs (backpressure respected), so the whole archive is never in |
| 110 | + memory; but each resource *is* fully buffered before its tar entry is |
| 111 | + written, because a tar header must state the byte size **before** the |
| 112 | + data. Trusting a HEAD `Content-Length` instead would corrupt the archive |
| 113 | + if the resource changed between HEAD and GET — there is no ETag-pinned |
| 114 | + read to prevent that race. Fine for pod-sized resources; a multi-GB single |
| 115 | + resource would hurt. (ustar's 11-octal-digit size field caps an entry at |
| 116 | + 8 GiB regardless.) |
| 117 | + |
| 118 | +- **No snapshot semantics.** The walk is not atomic: a pod written during |
| 119 | + the walk yields a mixed-state archive (old bytes for already-archived |
| 120 | + resources, new for later ones), and nothing in the api offers a lock, |
| 121 | + snapshot, or even a conditional multi-read. A backup of a quiet pod is |
| 122 | + exact; a backup of a busy pod is honest but fuzzy. Worth knowing for |
| 123 | + anyone treating this as disaster recovery. |
| 124 | + |
| 125 | +- **Skip counts can't go in a response header.** Streaming means the |
| 126 | + headers are committed before the walk starts, and fetch/Node expose no |
| 127 | + HTTP trailers usefully — so the bookkeeping *must* travel in-band. That's |
| 128 | + why `MANIFEST.json` is a tar entry (the last one) rather than an |
| 129 | + `X-Backup-Skipped` header. |
| 130 | + |
| 131 | +- **Permissions don't survive export — and can't, cleanly.** `.acl`/`.meta` |
| 132 | + are filtered from the walk (the same member filter `rss/`/`s3/` use). |
| 133 | + Including them would only need the caller to hold `acl:Control` per |
| 134 | + resource (the loopback GET would enforce that for free), but a tarball of |
| 135 | + `.acl` files full of absolute URLs doesn't restore meaningfully onto |
| 136 | + another server anyway. Real portability of *policy*, not just bytes, is a |
| 137 | + spec-level gap, not a plugin-api gap. |
| 138 | + |
| 139 | +- **`config.loopbackUrl` repetition — the `api.serverInfo` seam again.** |
| 140 | + The plugin must be told where its own host lives to make loopback calls; |
| 141 | + it fails loudly at `activate` when the config is missing. Same finding as |
| 142 | + ~10 other plugins in this repo. |
0 commit comments