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-23 15:42:55 +02:00
backend fix(scan): an overview of every root, and sizes that stop disappearing 2026-09-21 19:10:59 +02:00
frontend fix(frontend): guard paste, delete and create/rename against double submit 2026-09-23 15:21:22 +02:00
CLAUDE.md fix(scan): an overview of every root, and sizes that stop disappearing 2026-09-21 19:10:59 +02:00
opsdeck.module.json feat: browse, transfer and measure the host's files 2026-08-11 09:41:32 +02:00
README.md fix(scan): an overview of every root, and sizes that stop disappearing 2026-09-21 19:10:59 +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. The preview is syntax-highlighted — YAML, JSON, TOML, ini and systemd units, dotenv, shell, Python, JavaScript, TypeScript, CSS, Go, Rust — by the grammar the shell ships; a language it has none for renders plain rather than guessed at. 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. The scan page lists every root — what was measured, how long ago, and whether that answer is stale, partial or still running — and picking one switches the detail below it. A deployment with two roots has two answers of different ages, and both surfaces say so.
  • 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 a directory size means

A listing's directory sizes come from the last scan that covers that directory, not from the root's own scan. Where a root sits inside another — host=/ and pool=/fast-pool — the inner root's own measurement is what its row reports, which is the only right answer when the outer scan stopped at the filesystem boundary and never entered it.

A directory with no size shows —, and the note above the listing says why: the scan was cancelled, or its per-directory index stopped short. The index has a cap (the totals never do), and the directories nearest the root are kept whatever the cap says — they are the ones anybody opens. Beyond the cap a scan's totals are still exact; it is only the per-directory breakdown that thins out, and the scan page says so rather than calling itself partial.

Upgrading past this version discards the scans an older one wrote. They came from the walk that dropped those shallow directories, so keeping them would mean rendering the holes with no field to explain them; the first scan after the upgrade writes a whole one.

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.
  • The phone colours a preview with its own highlighter. The schema says codeList and passes the language, and each renderer answers with what it has: the web one loads the shell's tree-sitter grammar, the Android app runs a line-based colouriser that knows YAML and dotenv. A .py file on the phone is therefore coloured as if it were YAML — a rendering bug in the app rather than a wrong answer from here, and it costs the app one branch to fix.
  • 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;
  • CodeBlock and isHighlightLang in @opsdeck/ui — the highlighted preview. The grammars are the shell's, so what this module ships is the language NAME (langOf in fsops.ts) and nothing else: a core that colours more languages colours more of them here without a release of this.

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.