v0.35.0
filex v0.35.0
Self-hosted file manager — Go single binary + multi-framework frontend.
Download a binary below, or pull a Docker image:
docker pull ghcr.io/brf-tech/filex:slim-v0.35.0
docker pull ghcr.io/brf-tech/filex:full-v0.35.0What changed
Upgrade notes
-
⚠⚠ Running
FILEX_MULTI_TENANT? Upgrade. The tenant boundary was enforced
on the routes that list rows and assumed on the routes that name one. An
administrator of any tenant, pointed at another tenant's ids, reached fifteen
of the sixteen such routes — includingPOST /api/admin/users/{id}/reset-password,
which answered 200 and returned the other customer's new password in the
response body, andGET /api/admin/storages/{id}, which returned the storage
config: for an S3 or SFTP storage, the access key and secret./api/fileswas
worse still, because it serves bytes. All of it is closed. See Security below. -
⚠⚠ Everyone: thumbnails were readable without logging in.
GET /api/files/thumb/{id}
sat outside every auth group and returned a rendered preview — the readable
content of a document — to anyone who could guess a node id. This is not
specific to multi-tenant installs. Thumbnail URLs now carry a short-lived
signature, and an authenticated caller is checked. If you build thumbnail
URLs yourself, take them from the listing rather than constructing them;
a hand-built URL without the stamp is now a 401. -
⚠ Emptying the trash for one storage used to empty all of them. If you
have used Empty trash with a storage selected on an earlier version, it
purged every storage's trash, permanently, while the dialog said otherwise. -
⚠ Directory logins now refuse rather than mis-home. On a multi-tenant
install, LDAP and proxy-header logins used to provision accounts into the
supertenant, which sees every tenant. They now inherit the tenant from the
request's host; where there is no host to read (LDAP over SFTP/FTPS/NFS) the
login is refused unless you setauth.ldap.provider/FILEX_LDAP_PROVIDER,
because the only alternative was the supertenant. Existing mis-homed accounts
are not moved automatically — the boot log now names them and points at
PATCH /api/admin/users/{id}. -
⚠ Version restore now requires
editor. It required nothing before: a
read-only user could overwrite live file content with an old version. -
Five admin Settings controls were removed (
public_url,sync_interval_seconds,
log_level,default_locale,default_timezone). They wrote rows that
nothing read; the environment variables that do work are named in their place. -
storage.sync_modeis validated now. An unsupported value is rejected
instead of stored and silently polled. Rows that already carrypushkeep
working (they poll, and say so once in the log); only a change to an
unsupported mode is refused.
Security
-
GET /api/files/thumb/{id}served rendered previews to anyone who could
count. The route sat outside every auth group,checkSigreturned true when
thesigparameter was absent,manager.goemittedthumb_urlwith no
signature so no deployment was ever on the signed path, and nothing in the
codebase ever wrotesettings.thumb_signing_key— so thekey == ""branch
waved a supplied signature through too. Measured: an anonymouscurlwith no
cookie and no token got 200,image/jpegand the full body, and
?sig=deadbeefgot the same, on a server where anonymous
GET /api/files/quota/mecorrectly answered 401. Node ids are dense integers,
so that was "walk the range and collect the rendered first page of every file
on the instance" — and it was not a tenancy bug: a single-tenant install
leaked exactly as much. This is the most widely exposed of the three fixed
here.⚠ The fix could not simply be "require auth". Thumbnails render into
<img src>, which sends noAuthorizationheader, and filex's session cookie
isSameSite=Lax, so an<img>inside a third-party embed sends no cookie
either — a blunt auth requirement would have closed the hole and blanked every
embedded explorer in production, including a customer's. The endpoint now takes
one of two proofs: a short-lived stamp on the URL (?exp=&sig=, an
HMAC-SHA256 over node id + expiry under a key that is now actually generated),
which the listing puts on everythumb_urlit emits and which an<img>can
carry; or an authenticated caller who clears the node's tenancy scope, the
token'sroot:confinement and an ACL check at viewer level. Neither → 401.All four consumers were re-measured after the change: the admin SPA (cookie),
the desktop app (bearer), an<img>in an embedded@brftech/filexexplorer
(the stamp, no credentials at all) and the public share page — which never used
this endpoint and still does not, since it serves the same artefact through
/s/{token}/f/<path>?thumb=1, scoped to the share token. A root-confined embed
token now also stops at its own subtree: previously it could read previews of
any node id on the instance, because a node id is not a path and
confine.Middlewarehad nothing to rewrite.FILEX_THUMBS_URL_TTL(default24h, matching the endpoint'sCache-Control)
bounds the stamp; the expiry is quantized to the hour so the URL string — the
cache key for both the browser anduseThumbs— stays stable within a window
instead of forcing a re-download on every listing. -
LDAP and proxy-header logins provisioned into the supertenant.
db.Store.CreateUserhard-codesprovider_idto thedefaultprovider, and
defaultis seededis_supertenant = 1— whichCanAccessStoragetreats as
confine-exempt. Measured on a two-tenant install: a header-trust login
arriving ondiyetlif.localcreated the account withprovider_id = 1, and
that account's own storage listing came back as both tenants' storages. The
account was not mis-filed, it was privileged.A login has no caller whose tenant could be inherited — the account being
created is the caller — so the signal is the request Host, the same one
multioidc.Dispatcheralready uses to pick a realm.handlers.Auth.Login
stamps it onto the context (auth.LoginDriver.Logintakes only actx, so a
driver in the chain cannot see the request), the proxy-header driver stamps its
own, andauth.ProvisionUserhomes the new row — deleting it if homing fails,
as the invite path already did.⚠ The protocol logins (SFTP/FTPS/NFS, through
internal/protocolauth) present
a password on a socket and have no Host at all. There, on a multi-tenant
install, the driver now refuses to create rather than falling back: the
only fallback available is the supertenant, so "provision anyway" is the bug
rather than a convenience. An account that already exists signs in over every
protocol exactly as before. Newauth.ldap.provider/FILEX_LDAP_PROVIDER
(andauth.header_proxy.provider/FILEX_HEADER_PROVIDER, plus Helm
auth.ldap.providerand aauth.headerProxyblock that did not exist) let an
operator name the tenant explicitly when they want it.⚠ Rows an older build already stranded are not migrated. Nothing records
which driver created a user, and the break-glassadmin@localis deliberately
in the supertenant, so a blanket re-home would take an operator's own account
away from them. Instead a multi-tenant install with either driver enabled now
logs one WARN at boot naming every non-admin account homed in the
supertenant; re-homing staysPATCH /api/admin/users/{id}withprovider_id. -
/api/files/versionshad no ownership or ACL check on any install. The
Versionshandler had noACL *acl.Resolverfield at all — the only file
surface inroutes.gowith noAttachACLcall — andversioning.Service
verifies only that a version belongs to the node it names before overwriting
live bytes. Measured on a single-tenant install with aviewer-role
account:POST /api/files/versions/restoreanswered 200 andcontract.txton
disk went from its current content to the old version's, and
POST /api/files/versions/snapshotanswered 200 and wrote a version row. A
read-only account could roll back, and force snapshots of, any file whose node
id it could name.Every route in the file now resolves the node first and applies four gates —
existence, tenancy,root:confinement, RBAC. Restore and snapshot require
editor; listing the timeline stays at viewer, because somebody who can
read the file is not being told its history is a secret. The admin hard-delete
gains the same check (redundant behindRequireAdmintoday, deliberately, so
that un-admin-ing the route cannot silently drop authorization). The first
three gates answer 404, identical to a node that never existed; only the RBAC
refusal is 403.⚠ One behaviour change beyond the permission gate: a trashed node is no
longer reachable through these routes (404). A trashed row's live path is its
storage_key— where restore would put it back — so restoring a version onto
one wrote bytes to a path the catalogue says holds nothing. It is the same
exclusion the comments handler already makes.⚠ Restore is a destructive write and was the one write surface in filex
that went through neither half ofwritehook. It now calls
BeforeOverwritefirst — so the bytes it replaces are snapshotted, and a
snapshot that fails answers 503SNAPSHOT_FAILEDinstead of destroying them —
andOnFileWrittenafter, which is what finally makes a restore emit
file.updatedto webhook subscribers.snapshot_currentnow does work only
when that guard is switched off, so the same bytes are never recorded twice.
Fixed
-
A user could mute a notification event and keep receiving it.
muted_eventsandin_app_enabledround-tripped through
GET/PATCH /api/notifications/settingsfrom the day they were added and
no read path applied either of them: the history query filtered on the
user and the read flag only, so a muted event still appeared in the list and
still counted towards the unread badge. A preference that saves and does
nothing is worse than an absent one — the user believes the noise is handled.Both now gate the read:
in_app_enabled: falseempties the bell,
muted_eventsdrops those event ids from the list and the unread count.
What deliberately did not change is as important:Sendstill records
every event (the audit is not a preference), webhook delivery is untouched
(it is global), and the admin/global view is never filtered by one user's
mutes. The filter is applied in SQL rather than to the returned page, so a
filtered page comes back full and its total counts what the caller will
actually show. An unreadable settings row fails open — a display
preference must not be able to hide an antivirus hit behind a DB hiccup.docs/NOTIFICATIONS.mdsaid so in one place ("nothing applies them yet") and
contradicted itself in another, prescribing them under Too many
notifications as a live remedy. Both are now true. -
FILEX_AUTH_DRIVERS=proxy_headerenabled nothing. The loader matched
proxy-headerwith a hyphen;docs/CONFIGURATION.md(twice),
docs/ARCHITECTURE.mdand the Helm chart all told people to write the
underscore. Following the documentation produced one
unknown auth driverlog line and an install with reverse-proxy SSO that
reads as configured and silently is not. Driver names now fold_to-. -
The release pipeline could publish the 510 MB image as
:slim. The
default build was taggedslim/slim-vX.Y.Zand those tags were overwritten
by the slim build that followed — which is only true when the second build
runs. The job isallow_failure(the dind runner is unreliable) and pushed
with--all-tags, which is not selective. The full build no longer claims
those tags, and the push names the tags it built. -
Helm
Chart.yamlcreditedscripts/sync-chart-version.mjsfor keeping
appVersioncurrent. No such file exists; the script is
scripts/sync-deploy-versions.mjs. -
The storage badge labelled a storage with no
sync_modeas On demand,
while the backend defaults an unset mode to poll — the opposite of what
was running. -
FILEX_DEMO_PASSbroke the demo button it was supposed to configure.
The loader parsed it,docs/CONFIGURATION.mdlisted it, and no code read
config.Demo.Pass— while the demo landing's only button submitted the
literal stringdemoand the credentials hint under it printed the same
literal. An operator who set the variable therefore broke the demo login
while believing they had secured it, and the page went on advertising a
password that no longer worked.The password now travels the same road the demo user already did: the
capabilities response carries it and the CTA submits what it is given.
⚠ It is published only when demo mode is on — where the page prints the
credentials next to the button anyway, which is the entire point of a demo —
and a normal install returns nothing, whateverFILEX_DEMO_PASSsays. Both
fallbacks staydemo, so an install that never set the variable (including
the public demo, whose account password isdemoin the database) behaves
exactly as before. The documentation now also says the thing that was
missing: neither variable creates or changes the account. -
sync_modewas never validated, andpushwas a mode with nothing behind
it. Any string the admin API decoded was persisted, and the sync worker's
switch has no branch for it, sofsnotifiystored happily and the storage
ran the poll loop while the page displayed the typo back.pushwas the same
defect wearing a nicer costume: a declared enum member, no receiver, silently
polling.The gate lives in the store, not in one handler, because three writers
reach that column — the admin API, the config seed and the CLI — and the
message names the modes that exist.pushis rejected as unimplemented, with
a message pointing atondemand+POST /api/admin/storages/{id}/sync,
which is what an external writer actually wants.What happens to rows that already say
push: nothing is rewritten. They
keep polling as they always did, they stay editable — an unrelated rename or
disable still saves, since refusing it would strand an operator with a row
they cannot fix — and only a change to an unsupported mode is refused. The
worker now logssync: unsupported sync_mode, falling back to pollonce per
storage at startup, so the discrepancy is visible instead of silent, and the
storages list labels such a row Unsupported (polling) instead of the raw
i18n key. ⚠ The rejection currently surfaces as HTTP 500 with the message in
the body; mapping it to 400 belongs in the storages handler. -
Two load-bearing environment variables were undocumented.
FILEX_TESSERACT_BINdecides whether images are OCR'd into the content
index — and when set it is authoritative, so a wrong path turns OCR off
rather than falling back to$PATH, which is exactly the kind of thing an
operator must be told before they debug an empty index.FILEX_UPDATE_TARGET
is the one variable indocs/CONFIGURATION.mdthat filex sets rather than
reads: it is exported intoFILEX_UPDATE_PRE_COMMAND's environment with the
version about to be installed, so a backup command can name its dump after it.
Both are now indocs/CONFIGURATION.md, with what happens when they are unset. -
Helm
nameOverridewas rendered by every template and declared nowhere.
_helpers.tplhas always read.Values.nameOverride, so it worked — for
anyone who read the templates. A value you can only discover by reading the
chart's internals is not configurable in practice; it is now declared in
values.yamlwith what it changes (helm template rel ./filex --set nameOverride=custom→rel-custom-*). -
Five of the six controls on the admin Settings page did nothing.
public_url,sync_interval_seconds,log_level,default_localeand
default_timezonewere PATCHed, written to thesettingstable, echoed back
and re-rendered in the form — and no code on the server ever read those rows.
The live values come fromFILEX_PUBLIC_URL,FILEX_SYNC_INTERVAL,
FILEX_LOG_LEVELandFILEX_DEFAULT_LOCALE; there is no timezone knob at
all.site_name, the one key with a reader (share-invite mail), sat on the
same form and made the page look trustworthy, and the hints promised
specifics — "Used for share links and email templates" under a field that
changed no link.The five controls are gone, replaced by a line naming the variables that do
work. Nothing is migrated: the stale rows are harmless and were never read. -
A failed sync was painted the same grey as one that never ran. The
backend writes"failed"(internal/sync/poll.go); bothsyncTonecopies —
Storages and Dashboard — matched'error', the spelling the sync-runs list
translates to. So the badge text said "failed" while the dot, which is what
an operator actually scans a list for, said "nothing to see". Both spellings
are now accepted, and there is one copy of the mapping
(web/src/lib/syncTone.ts) instead of two that drifted identically. -
The dashboard's storage cards always read "0 B · 0 files". They render
the same rows as the storages list, which readsstats.total_size_bytes
with the flat legacy field as a fallback; the dashboard read only the flat
field, which the endpoint stopped filling when the nestedstatsobject
arrived. -
The archive preview ignored
apiBase.ArchiveViewerhardcoded
fetch('/api/files/archive/list')while every other call in
@brftech/filex-coregoes through the configured endpoints — so in the
package's headline use case, an embed on a host page pointed at
https://files.example.com, the zip preview posted to the host page's
origin and 404'd. It now takes the endpoint from the explorer's config and
falls back to the same-origin path. -
showInfoPanelwas documented public API that nothing read. An embedder
who set it false got the inspector toggle anyway. It now hides the toggle, as
documented; default (and every existing embed) unchanged. -
The SMB driver had no translations. Seven i18n keys its descriptor names
(storages.driver.smb,fields.share,fields.domain,
fields.dialTimeout,fieldHelp.smb{Share,Domain,Root}) existed in neither
catalogue, so SMB was the one driver whose form rendered in English inside
the Turkish UI.
This release has more to it than fits on one page. The rest of the
entry — and every earlier release — is in CHANGELOG.md.
Verify: sha256sum -c checksums.txt
- Documentation — https://docs.filex.sh
- Report a bug — https://github.com/BRF-Tech/filex/issues
- Full changelog — https://github.com/BRF-Tech/filex/blob/main/CHANGELOG.md
- Every release — https://github.com/BRF-Tech/filex/releases