Repository navigation
v0.33.0
filex v0.33.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.33.0
docker pull ghcr.io/brf-tech/filex:full-v0.33.0What changed
Added
-
An i18n key-parity test for the
@brftech/filex-corecatalogue
(web/tests/i18n/coreKeys.test.ts).webalready had one for its own
en.json/tr.json;packages/coreshipsen.ts/tr.tsbut has no test
runner of its own, so the gate lives inweb, which depends on the package.
It also fails on an "English" value that is still Turkish — the shape the
bug above would take once it wears a key. -
Almost every absolute URL a multi-tenant install handed out was the
operator's hostname, not the customer's. Exactly one place in the codebase
derived the origin per request —Auth.redirectBase, written for the OIDC
callback and never reused. Everything else concatenated the global
FILEX_PUBLIC_URL: the/s/and/d/links a tenant sends to their own
clients, thewss://…/api/wsendpoint handed to the browser and opened
verbatim, the/s/and/u/URLs returned to AI / MCP / ShareX clients, and
four e-mails. The worst was the account-created mail: a customer's brand-new
user received a temporary password next to a link to someone else's login
page — a flow that cannot succeed, and a disclosure of the operator's
hostname to every tenant that ever adds a user.The rule now lives in one place,
internal/tenanturl, and every builder
calls it. It has two entry points because not every URL is minted while a
browser waits:FromRequestfor the request-driven sites, and
ForStorage/ForProviderfor the ones reached only through a context (the
AI/MCP share link and the upload ticket), where the tenant is taken from the
node's storage rather than from aHostheader that isn't there.Host-header injection: the request host is resolved with
GetProviderByHost, which matches an enabled provider row exactly, and the
origin is then assembled from that row's ownhostcolumn — never from the
request string. An unknown, disabled or absent host falls back to
PublicURL, soHost: evil.examplemints the operator's configured URL and
evil.examplenever reaches a link or an e-mail.Single-tenant installs are unchanged. With
FILEX_MULTI_TENANToff the
resolver does not read the request at all (asserted, not assumed: the test
counts store lookups and requires zero), so the call sites that have no
request in scope lose nothing. Covered by a new multi-tenant suite that
drives the real handlers — and, for the four e-mails, a real in-process SMTP
server, so the assertion is on the bytes a recipient receives.- The public export's link check had never run once.scripts/export-public.sh
ends by runningscripts/check-links.mjsover the tree it just built and
refusing the export if anything is dead — the guard added on 2026-09-05 after
two dead links sat in the public README. It was wrapped in
if command -v node; else echo "node not found: skipped…"; fi, and on the
maintainer's machine it took theelsebranch every time: the script is
invoked aswsl bash -lc 'bash scripts/export-public.sh …', and that WSL had
nvm and three node versions installed butnodeon no shell's PATH — the
interactive guard in~/.bashrcreturns before the nvm block for
non-interactive shells, and for interactive ones a hard-codedexport PATH=
two lines later wiped what nvm had just added. The export succeeded, printed
its own excuse, and nobody read the line.A guard that degrades to nothing is not a guard, so no node is now fatal,
and the script looks for one before giving up:PATH, then the newest
$NVM_DIR/versions/node/*/bin/node, then — under WSL — the Windows
node.exe. ⚠ That last one is a Windows binary: handed/mnt/g/…it
resolves the path against the current drive and dies with
Cannot find module 'G:\mnt\c\…', so both the checker and the tree are
converted withwslpath -wfirst. All three branches were exercised:
nvm node →410 relative links … all resolve; Windows node → the same 410;
no node at all → exit 1 with a refusal. A deliberately dead link
(README.md→docs/MIGRATION.md, which the export withholds) is refused
with exit 1.
Fixed
-
The admin UI named a private repository, and every published screenshot
showed it. The footer and the About page linked
the internal development repository, which
answers 404 to anybody who is not us.scripts/export-public.shrewrites that
string on the way out, so the shipped source was right; a screenshot is
a PNG and no rewrite reaches inside one, so the images in the public README
showed the dead URL. They now namegithub.com/BRF-Tech/filex, which is where
the product actually lives — including on our own installs, where a link to a
repository the reader cannot open was never useful either. -
Uploads over 8 MiB never reached an S3 storage over plain HTTP, and the
sync then moved them to trash (GitHub #16). Reported against a fresh Garage
deployment: every upload looked like it worked, the bucket stayed empty, and
the next storage sync put the files in the trash. Files under a few MB were
fine. Reproduced end to end against a real Garage instance.Two independent defects, and the second is the one that cost the user data.
1. The commit could not sign a multipart part. The browser posts files
under 8 MiB in one request; above that they take the staged path, where the
commit re-chunks the staging area into a driver multipart upload. Each part
was cut withio.LimitReader, which drops theSeekmethod the staging
reader has. SigV4 signs the SHA256 of the payload, so the SDK reads a part to
hash it and then rewinds to send it — with nothing to rewind the request died
inside the client, before a byte left the process:
upload part 1: operation error S3: UploadPart, failed to compute payload hash: failed to seek body to start, request stream is not seekable.⚠ It is the SCHEME that decides this, not the provider — measured, because
the obvious guesses are wrong. Overhttps://the SDK sends
x-amz-content-sha256: UNSIGNED-PAYLOAD: TLS already protects the body, it is
never hashed, and a plain reader has always worked. Overhttp://the payload
hash is what binds the body to the signature, so the body must be read twice.
Every S3 endpoint filex had previously been pointed at ishttps://; the
reporter's Garage, a container on a podman network, is not. The bug was
waiting for the first plaintext endpoint, and it would have hit AWS, MinIO or
Hetzner over plaintext just the same.Parts are now cut with a rewindable, length-bounded window over the staging
reader, andUploadParton the S3 driver makes any body rewindable before it
hands it to the SDK — small ones in memory, large ones through a temp file,
an already-seekable one passed through untouched. The driver's signature
promisesio.Readerand now honours it, instead of silently requiring a
Seekerand failing with a message that names the SDK rather than the call.2. A failed upload was silent. The commit endpoint answers
202as soon
as the last chunk lands; the transfer happens afterwards in the ops worker. A
transfer that failed there produced oneWARNline in the server log and
nothing else — the node stayed attransfer_state="staged", which is
indistinguishable from still-in-flight, no notification fired, and the browser
had already drawn a finished upload. The client now waits for the transfer by
default (thetransferringphase, which was implemented but which no caller
ever switched on) and reports the failure; the node moves to a new
transfer_state="failed"; and afile.upload_failednotification, carrying
the reason, goes to whoever uploaded.⚠ While turning that wait on, a latent bug in it surfaced: it told a real
verdict from an incidental fetch error by comparing the message to the literal
string"transfer failed", which only matches when the server sends no error
text. Any real error message was swallowed and the poll span for ever. The two
are now told apart by type, and a run of unreadable polls ends the wait
instead of hanging. -
Uploading a file over an existing one destroyed the old bytes, and nothing
kept a copy anywhere.versioning.Service.Snapshotdocuments its own
contract — "callers should invoke this BEFORE a destructive write… if the
snapshot itself fails the caller should NOT proceed" — and the package doc
stated as fact that snapshots were taken on upload finalize and archive
extract. Neither was true. Across the whole productSnapshotwas reached
from exactly two places:save-textand the versions endpoints. No upload,
drop, ShareX, AI/MCP write, archive extract, OnlyOffice save-back, WebDAV
PUTor S3 gateway write ever called it. Measured on a clean instance:
uploadingup.txttwice leftGET /api/files/versions?node_id=1answering
{"versions":null}with no.versions/tree on disk at all, while editing
the same file in the browser recorded one — so version history looked like
it worked right until the moment you needed it.Every destructive write now calls
writehook.BeforeOverwritefirst, which
snapshots whatever is about to be replaced and refuses the write if that
snapshot cannot be taken: 503 with"code": "SNAPSHOT_FAILED", existing file
untouched. The covered surfaces are the browser upload (single-POST and
staged), the public drop link, the ticketed upload, the legacy presigned
multipart finalize, ShareX, the AI/REST and MCP write/zip/unzip tools,
archive extract and add, OnlyOffice's save-back, WebDAVPUT, and the S3
gateway'sPutObject,CompleteMultipartUploadandCopyObject.⚠ On the staged path the guard runs at commit, not at the driver write.
Two reasons, and both are load-bearing. Publishing the staged node flips it
totransfer_state="staged", after whichfilebodyanswers with the
incoming bytes — a snapshot taken later would record the wrong content.
Andtransfer()picks between two write mechanisms: on any driver
implementingstorage.PartUploader— i.e. S3, which is what real
deployments run — it callsstreamMultipartand never touches
storage.Writer.Writeat all. A guard hung off the driver write would have
protected small uploads and silently skipped every large one on exactly the
backend where it matters most. There is a test pinning the S3-shaped case
that asserts the object really did arrive viaCompleteMultipart.save-textis brought under the same rule rather than left as the
exception: it used to logsnapshot failed (continuing with write)and
overwrite anyway, directly contradicting the contract quoted two lines above
its own call.Archive extract and the AI/MCP
unziptool differ deliberately: they skip
just the refused member and keep going, reporting arefusedcount distinct
from the permanent, user-caused skips in the same loop (a zip-slip entry, a
file/folder kind clash). If every member was refused they answer 503 rather
than a misleading200 {"count":0}.FILEX_VERSIONS_ON_OVERWRITE=0turns the guard off for a deployment whose
storage cannot afford the extra write;FILEX_VERSIONS_FAIL_OPEN=1keeps
attempting the snapshot but lets a failed one through. Both default to the
safe value and log a WARN at boot when they are not at it — the fail-closed
default has a sharp edge worth naming, because if the object store fills up
then every overwrite on the instance is refused until an operator changes an
env var and restarts. -
The S3 gateway exposed filex's own internal trees.
.versions/,
.thumbs/and.filex-trash/were listable AND readable by known key
through/s3— measured,GET .versions/42/1answered 200 — while/dav,
/sftp,/ftp,/nfsand the browser listing had all hidden them since
they were written. They are now refused at any depth on listing, read and
write. This was always wrong and became urgent with the guard above: before
it,.versions/held a handful of text-editor snapshots; after it, it holds
a copy of every file any surface has ever replaced, so leaving it reachable
would hand any S3-key holder the prior contents of files whose folders they
may since have lost access to. -
A failed upload named neither the file nor the reason. The access log
said onlymethod=POST path=/api/files/manager status=500, and no
write-failure branch logged a filename, so "which file failed yesterday
afternoon" had no server-side answer. Both the browser upload path and the
staged transfer — whichever of its two driver write mechanisms fails — now
logmsg="upload failed"withstorage,path,name,sizeand
reason. -
POST /api/files/archive/addleaked a file descriptor on every error
path. Only the success path closed the temp file it built the archive in;
each of the early returns dropped it. Closed with adefer, registered
after thedefer os.Removeso the two unwind in the right order. -
filex spoke Turkish to English users, and no locale setting could stop
it. ~45 strings across the explorer, the viewers, the admin UI and the
backend were hard-coded Turkish literals sitting beside the translation
layer, not inside it. They are now keys inen/trand go throught().The widest one was the whole convert modal. It resolved its labels
through a privatett(key, fallback)helper backed by an optionalt?prop
— and three separate things had to be true for it to ever produce English:
the caller had to passt(FileExplorer.vuenever did), theconvert.*
keys had to exist (they existed in neither catalogue), and the prop's
signature had to matchuseLocale's (it did not — it declared
(key, fallback)where the realtis(key, vars), so passing the real
one would have rendered the raw key, or spliced the fallback's characters in
as{0},{1}… substitutions). Every user in every locale read Turkish.
The helper is gone: the modal now takeslocaleand callsuseLocale, like
every sibling modal, and its ten labels are real keys.Also: ~20 explorer toasts and the drag-and-drop overlay
(İşlem başarısız,Kopyalandı/Taşındı/Silindi,Kesildi,
Aynı klasöre kesilemez,… kuyruğa alındı,… öğe geri getirildi, the
trash-retention notice,Dosyaları buraya bırak); the new-folder validation
error; the presence bar's people count; the CSV viewer's row count; five
strings in the preview modal (two of them sitting next to a correct
{{ t('viewer.download') }}); and the SMTP password placeholder in
Settings.vue, whoselabelandhinton the same line were already
translated.Where a key already said the same thing it was reused rather than
duplicated — the sharpest case being the paste toast, which was
t('split.cross_copy')on the cross-storage branch and a Turkish literal on
the same-storage branch of the same expression, while
split.copy_queued('Copy queued' / 'Kopyalama kuyruğa alındı') already
existed and was exactly it. -
A file drop notified the folder's owner in Turkish regardless of their
language.Drop.notifyOwnernever consulted a locale, and its output is
not confined to one surface: the same title and body go to the in-app
notification bell, into the webhook v2drop.receivedpayload and into
the owner's e-mail. TheGönderen:header written into the persisted
NOT.txtbeside the uploaded files had the same problem.These now follow the same
mailLangENselectionmail_templates.gonext
door already used. The locale is the folder owner's (users.locale, via
the newDrop.ownerLocale), not the uploader's: a drop link is opened by an
anonymous visitor who has no account and therefore no locale, and the owner
is the only person who reads any of it. -
The admin panel's SMTP Test mail sent a Turkish sentence followed by an
English one to whatever address was typed. It now follows the acting
admin's locale. -
DrawioViewer's untranslated fallbacks were Turkish where every sibling
fallback (e.g.CsvViewer's'Loading…') is English. The fallback is what
an embedder who does not passtactually reads. -
desktop: the drag-out diagnostic trail logged'BULUNAMADI'where every
neighbouringdragLogvalue is English. This one is not a translation
key — it is a developer log, and it is now'not found'.
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