No description
  • TypeScript 80.8%
  • Svelte 19.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-02 16:09:07 +02:00
backend test(contract): a line that looks one root up may not walk the rest of them 2026-09-02 11:35:34 +02:00
frontend fix(overview): no df, no bar — the case the README already promised 2026-09-02 08:22:20 +02:00
CLAUDE.md docs: the card's subset has an owner, and the tripwire has a rule 2026-09-02 08:22:41 +02:00
opsdeck.module.json feat: browse, transfer and measure the host's files 2026-08-11 09:41:32 +02:00
README.md docs: the card answers about a root too, and says what it counts 2026-08-24 21:20:20 +02:00
tsconfig.check.json fix: a tsconfig that disabled the module, and a landing path outside the roots 2026-08-11 17:07:43 +02:00

filebrowser

Browse, move and measure the host's files from OpsDeck.

An external module: its own repository, cloned and built at container startup, pinned by commit. It runs in-process as the OpsDeck server — which is host root — so read the security section before installing it.

OPSDECK_EXTERNAL_MODULES=https://git.imhof.cloud/OpsDeck/module_filebrowser.git#<sha>

What it does

  • Browse every configured root: name, size, modified, mode, owner, symlink target, with breadcrumbs, a live filter and a hidden-files toggle. Each row carries a small kind glyph — folder, image, video, archive — which is the first thing dropped when the window is narrow.
  • Inspect a file: full metadata, a text preview with a binary refusal, and a streaming sha256 on request. Images render inline — in the details panel, and as a thumbnail when a name is hovered or focused.
  • Scan a root in one pass and learn both where the space went (recursive directory sizes, largest directories and files) and what it went into (bytes and counts per category and extension). Off, on demand, or on a schedule.
  • Transfer: download a file (with range requests, so an interrupted download resumes), download a folder or a selection as one streamed archive, upload with per-file progress and gzip transport compression — by button or by dropping files anywhere on the page.
  • Drag entries onto a folder or a breadcrumb to move them, hold the copy modifier to copy instead; a directory refuses to be dropped on itself or into its own subtree.
  • Change: create, rename, copy, cut/paste, delete — the long ones as task runs, so progress survives a reload and appears in Activity.

Everything long-running is a core task run (ctx.tasks): scan, copy, move and recursive delete. Closing the page does not cancel them.

Configuration

Every variable is OPSDECK_MOD_FILEBROWSER_<KEY>. Defaults are the safe end of each range; an unparseable value fails at startup naming the variable, and an unrecognised role or mode narrows rather than widens.

Key Default What it does
ROLE admin Minimum role that may see the module at all. viewer widens it and logs a warning.
WRITE_ROLE admin Minimum role for mutations; never resolves below ROLE.
READONLY 0 1 refuses every mutation server-side and hides the controls.
ROOTS the whole host media=/srv/media,configs=/etc. When set it is the complete set; nothing outside is reachable.
DENY Extra globs refused for read and write, e.g. /srv/secret/**. Extends the built-ins, never replaces them.
ARCHIVE tar.gz Default format for folder downloads: zip, tar.gz or tar. All three stay available per request.
SCAN ondemand off, ondemand or periodic.
SCAN_INTERVAL 6h Schedule for periodic (30m, 6h, 1d).
SCAN_PACE_MS 0 Sleep every 500 entries, to leave IO for everything else.
SCAN_STALE_HOURS 48 Beyond this, scan-derived sizes are labelled stale.
SCAN_CROSS_FS 0 1 lets a scan cross filesystem boundaries.
TYPES Extend the category map: video=mkv,mp4;photos=cr2.
MAX_UPLOAD_MB 2048 Enforced while streaming, not after.
PREVIEW_KB 512 How much of a text file the preview reads.
IMAGE_PREVIEW_MB 12 Above this an image is offered as a download; there is no thumbnailer in the container, so the browser scales the original.
LIST_LIMIT 2000 Entries per listing response; the total is still reported.
SHOW_HIDDEN 0 Show dotfiles by default.
STATE_DIR $OPSDECK_DATA_DIR/modules-state/filebrowser Scan cache. Safe to delete; costs a rescan.
FAKE 0 Serve a synthetic tree instead of the host (also on with OPSDECK_FAKE_DATA=1).

The built-in deny list — /proc, /sys, /dev, /run — always applies. If your OpsDeck data volume is a host directory, add it to DENY: the module would otherwise let someone delete the dashboard's own database.

Security

Installing this module gives everyone with the read role the ability to read every file the server can, and everyone with the write role the ability to change them. With the default ROOTS, that is the whole host.

What the module does about it:

  • Every route carries a role guard, GETs included. Mounting under /api/mod/filebrowser provides authentication, not authorization.
  • Path confinement. Client input is resolved segment by segment; .. is collapsed during the walk and the result is re-checked against the roots on a segment boundary, so /srv/data-old is not inside /srv/data. Anything outside answers 404, never 403 — a distinguishable refusal is a probe.
  • Symlinks are resolved in the host's namespace. An absolute link target inside the host mount means the host's root, not the container's; realPath() would happily walk out of the mount into the container's own /usr. A link leaving every root is listed and marked, never followed.
  • Downloads are always Content-Disposition: attachment + nosniff. A planted .html or .svg must not execute in the shell's origin.
  • One route serves bytes inline, and it never trusts the file name. Image previews are typed from the magic bytes against a raster allow-list (PNG, JPEG, GIF, WebP, AVIF); anything else is refused with 415. SVG is not on the list — it is the one image format that is a document.
  • Audit trail. One line per download, upload, mutation and refusal: user, operation, path, bytes, outcome. Never file content.
  • A live topic follows the role of the action it reports on, so a progress stream is not the door the role check forgot. changed and scan are admin-only outright. scan-progress is admin-only exactly when reads are — scanning is a read-role action, and an operator allowed to start one should be able to watch the one they started. delete-progress follows the write role, so ROLE=viewer with WRITE_ROLE=admin does not let a viewer watch deletions they can never start. Either way a line says no more than the subscriber could already list: counts and a name, never file content, and on failure the reason the action declared rather than the error text — run fields are withheld per field, and a topic has no projection to withhold them with.

What it deliberately does not do

  • No per-path permissions. OpsDeck has two roles; a finer model invented here would be enforced nowhere else.
  • No public share links, no WebDAV. A second, session-less path around every guard above.
  • No trash. Deletes are real; the confirmation is the safety net.
  • No editor. Editing belongs to a separate module; this one links to it when it is installed.

Platform limits worth knowing

  • Creating symlinks is impossible in the container. Deno refuses symlink() unless --allow-write is unscoped, and OpsDeck scopes it. A copy that meets a symlink skips it and names it in the run log rather than failing the whole copy.
  • Mobile cannot download or upload bytes. The portable UI schema has no block that opens a URL and no input kind that picks a file, so the Android app gets browse, inspect, preview, create, delete and the scan; transfers are web-only until the schema grows those verbs.
  • df provides free space — one cached call per path, shared by the web routes and the schema routes, and it is df's Available rather than total - used, which counts the filesystem's reserve as space you can write. A deployment without coreutils shows no usage bar rather than a wrong one. The roots behind that number are probed together and by one helper, and the two web surfaces report them through one mapper, so the file listing, the landing page and the dashboard card cannot disagree about whether a root is readable — a root a deny rule covers is unreadable on all three, and the card says so rather than drawing the empty bar it used to. The card renders the roots it has room for and counts the rest; that count is of the configured roots, not of the ones it asked about.
  • A progress topic belongs to the action, not to the run. progressTopic is a fixed string in the schema with no room for a run id, so scan-progress and delete-progress are module-wide: with SCAN=periodic a scheduled scan of one root finishing ends the stream a phone was watching for another, and the second of two queued deletes does the same to the first. The run itself is always exact — Activity, the web UI and the runId the route answers with all follow one run — so this is a limit of the portable mechanism, not of the work. It goes away when the schema can name a run.

Development

No standalone build — the module builds inside an OpsDeck core checkout:

cp -r . <core>/packages/modules/filebrowser
cd <core>
deno check packages/modules/filebrowser/backend/mod.ts
deno run -A packages/module-builder/main.ts \
  packages/modules/filebrowser /tmp/fb-dist filebrowser   # the import contract
OPSDECK_AUTH=disabled OPSDECK_FAKE_DATA=1 \
  OPSDECK_MODULES=filebrowser deno task start

Tests need no core checkout and no network — the repository has no dependencies:

deno test --allow-read --allow-write --allow-run=python3 backend/

--allow-run=python3 is for the archive tests: the zip and tar output is verified by Python's zipfile and tarfile, because an archive writer that only its own reader accepts is not a working archive writer.

Which core it needs

SDK major 1, plus core with:

  • the task system (ctx.tasks / ctx.schedules) — scan, copy, move and delete are runs, not promises;
  • AccessDenied and ActionProgress in @opsdeck/ui — the permissions page and the two-density progress view live in the shared kit rather than here, so both are one Svelte runtime and one copy for every module that wants them.

v0.1.0 predates that second requirement and carries its own copies, so it installs against core as it was released. Anything after it needs a core that ships the components.