Projekt
Changelog
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
Changed
-
The two form placeholders that named a domain now use
example.org. The SMTP server field saidmail.beispiel.de, the share dialogname@beispiel.de. Nobody sends mail to a placeholder, so nothing was at risk — but it is the same rule as the one.env.examplefollows since 3.3.1, and leaving two exceptions behind means the pre-release check reports the same two lines forever until somebody stops reading it. -
The test pipeline installs the
gdextension.fileshare:checklists it as required because the two-factor QR code needs it; a test container without an extension the product requires cannot show that the product runs. Costs about 15 seconds of build time.
3.3.1 — 2026-09-19
Changed
-
.env.exampleusesexample.orginstead ofbeispiel.de. Both read as placeholders andbeispiel.deis in practice kept as one, but it is a real registration held by a private company — whose terms can change.example.orgis reserved by RFC 2606 and can never be registered by anyone. It matters most for the two values a copied file carries furthest:APP_URL, which ends up in every link a system mail contains, andMAIL_FROM_ADDRESS, which receives the bounces.Existing installations are unaffected —
.envis never overwritten by an update. The value applies to new installations only. -
RELEASE.mdgained a step that greps for example addresses before tagging. It was missing, and the ad-hoc version used for 3.3.0 filtered out anything whose file name contained.example— which is precisely.env.example. It reported a clean run for a release that carried the value above.
3.3.0 — 2026-09-19
Added
-
A product site on GitLab Pages, at
https://fileshare.peuser.dev. It has two parts and both are built by thepagesjob fromsite/:- a hand-written presentation page (
site/index.html), and - the repository's own Markdown, rendered — the documentation is not maintained a second time.
The build derives everything a public page needs from the sources it already has: page descriptions from the first paragraph, the FAQ's structured data from the headings, the version from
CHANGELOG.md,lastmodfrom the commit date of each source file. It also fails the pipeline when a link has no target, because a dead link in an installation guide is worse than a missing paragraph.Two things an operator should know:
site/is excluded from deployment and from the release package (.rsync-exclude), so nothing about this reaches a target server; andpages.publishis set tosite/dist— without it GitLab would publishpublic/, which in a Laravel project is the application's web root. - a hand-written presentation page (
Changed
-
The documentation is now one page per topic.
docs/ADMIN-HANDBUCH.mdanddocs/BENUTZERHANDBUCH.mdwere two files of 944 and 405 lines with their own tables of contents in the body text; they are now 35 pages underdocs/, grouped into Erste Schritte, Betrieb and Benutzen. Nothing was dropped — chapters became files, and the cross-references between them became links that the site build verifies.The rules this follows are written down in the new
DOCS-CONVENTIONS.md: a guide says what to do and does not justify it, one procedure is described in exactly one place, and the form of address is fixed per section (du for operators, Sie for users, matching the interface).The two handbooks now sit under
docs/_archiv/with aREADME.mdthat says which page replaced which chapter. They are no longer maintained, they are not inSEITEN, and nothing links to them — they are kept because a sentence may have been lost in the split and this is where to look for it. -
INSTALL.mdis the short version again — 403 lines down to 131. It ships inside the release package, so it keeps the commands; everything it used to explain a second time (requirements table, token handling, the anatomy ofupdate.sh, the reasoning about the setup script's checksum) now lives once, indocs/, andINSTALL.mdlinks there. -
The READMEs point at the published documentation instead of at the two handbooks.
-
composer.jsonnames its homepage and its support links (docs,source,issues) — whatcomposer showand a package listing display. -
The release notes tell you to read the changes before updating. The paragraph sits above the update commands, because whoever jumps to "Updating" on a release page copies the lines and does not scroll on — and a major release can require a manual step that is due beforehand.
-
Tailwind no longer scans
site/. The presentation page is real markup with real utilities, but with its own tokens and its own build; unfiltered, its hero and grid classes would land in the application's stylesheet, which never uses them.
Removed
- The unused Quicksand 300 weight is no longer declared. No element asks for
font-weight: 300, so the@font-faceblock only offered the browser a file to fetch. The.woff2stays underpublic/fonts/.
3.2.0 — 2026-08-31
An uploaded theme can now set the colour of a single element instead of only the palette behind all of them.
Added
-
A per-element layer in
tokens.css. The theme file now has two layers an operator can set, and the distinction is the whole point:--c-…— the palette. Eight values (page background, text colour, line, accent, once for light and once for dark) and the entire installation looks different. This is what was there before and it is unchanged.--e-…— one token per element and property:--e-bodyis the body text,--e-headingthe headings,--e-labelthe field labels,--e-card-linethe card border,--e-buttonthe button surface,--e-navthe header bar,--e-table-headthe table head,--e-input-linethe field border. Every one of them points at the palette by default, so a theme that ignores this layer behaves exactly as before.
What this fixes is not cosmetic. Body text, headings, form labels, help text, table cells, menu entries, card borders, table borders, secondary buttons and badges all hung on the same two variables —
--c-inkand its derived--c-border. Body text is black or white in almost every theme; card borders and headings usually are not. An operator who wanted "headings in our blue, body text black" had no way to say it: changing the text colour moved eight other things with it. Now it is one line::root { --e-heading: #1d4ed8; }A fixed value applies in both modes — inheritance from the palette is cut at that point. Repeat it under
.darkif it should differ there, and check the contrast in both. -
The drop area and the progress bar are themeable on their own.
--e-dropzone,--e-on-dropzone,--e-dropzone-lineand--e-dropzone-active(the surface while a file hangs over it) carry the large area of the upload pages;--e-progresscarries the bar,--e-trackthe groove beneath it. They deliberately do not share the small file field's tokens: that area is the main thing on those pages and the first thing anyone wants to brand, and the field tokens are mode-constant — hung on those, the area would have stayed white in dark mode. -
Hover is graded. A shared pair
--e-hover/--e-on-hoveris the default for every inverting hover surface, and each interactive element has its own on top of it (--e-button-hover,--e-nav-hover,--e-menu-hover,--e-card-hover,--e-dialog-hover,--e-toast-hover,--e-subnav-hover, plus the secondary button). One value changes every hover; another changes only the button. -
Form fields separate text from border.
--c-field-inkused to be both, so a grey border with black text was not expressible. The new set is--e-input,--e-on-input,--e-input-lineand--e-input-hint(placeholder), plus--e-input-checkfor the checked state of a checkbox or radio. -
tests/Unit/DesignTokensTest.php— a guard over the token graph, run from PHP because every failure mode here is silent. It checks that every--e-…token is wired to a utility (an unwired token is a promise the interface does not keep: the operator sets a value and nothing happens), that every wiring points at a token that exists (the typo that leaves a utility with no colour), that the comments intokens.cssare balanced, and that no template reaches back to the palette.npm run buildreports none of these. -
Settings → Appearance explains the two layers before the file is opened. Without it the obvious move is to change
--c-ink, because it is the first colour in the file — and that is precisely the move the element layer exists to make unnecessary.
Changed
-
Templates now name the element, not the palette.
text-inkbecametext-body,text-heading,text-label,text-help,text-on-table,text-on-nav,text-on-menuand so on, depending on what the text actually is;border-inkbecameborder-card-line,border-table-lineorborder-dropzone-line;bg-brandbecamebg-button,bg-accent,bg-progressor one of the hover tokens. Nothing changes visually — all 299 changed utility pairs were resolved against the built stylesheet in both modes and compared; every one lands on the same colour as before.The old names still exist in
tokens.cssso that a theme uploaded under 3.1 keeps working, including one that sets--color-field,--color-field-ink,--color-field-muted,--color-on-overlayor--color-on-overlay-ink: those five are read as the default of the corresponding element token and still take precedence. They are no longer allowed in a template, and the new test enforces that. -
Icon actions use the secondary button's tokens instead of a set of their own. An icon action is a secondary button, and two token sets for the same weight would have drifted apart.
Fixed
-
The changelog's link definitions and
INSTALL.mdstill named 3.0.1 after 3.1.0 shipped. Nothing breaks from that — the sentence just starts lying, which is whyRELEASE.mdlists those places by name. -
Editorial pages were unreadable in dark mode. The typography plugin ships its own grey palette (
--tw-prose-*) which knows neither this theme nor the dark mode unlessdark:prose-invertis set — and it was not. Body text on pages from the database rendered atoklch(37.1% 0 0)on black, roughly 2.7:1, below the WCAG 1.4.3 threshold. The plugin's variables are now bound to the element tokens, so editorial content follows the theme and the mode like everything else.prose-neutralwas dropped from the markup: it set exactly those variables back to grey.
3.1.0 — 2026-08-26
Added
-
fileshare-setup.sh— one script for both getting started and staying current. It runs on the target server, fetches the latest release from the package registry, verifies the SHA256 checksum, unpacks, places the files and installs the dependencies.It does not ask what you want; it looks at the target directory. Empty or missing means a fresh install. A recognisable Fileshare installation means an update, and it hands over to the
update.shfrom the freshly downloaded package — the order of an update belongs to the version being installed, not to the one being replaced. Anything else means it stops without downloading or overwriting anything: a mistyped path must not clear out somebody else's directory.It deliberately stops short of
fileshare:installand prints what is left to do. A database password and the first administrator's password passed on a command line would sit in the process list and in the shell history.The script lives in the repository, not in the package: it has to be able to fetch a version it does not know about yet. Fetch it with
curl -fLO, read it, then run it — piping a script into a shell runs code nobody has seen, and on a dropped connection runs it half way. It carries no checksum of itself, and that is deliberate: the only place to publish one would be the same repository the script comes from. What a checksum on a script actually protects against is a half-transferred file, and that is covered by wrapping the whole body in{ … }— a brace group has to be read to its closing brace before anything inside runs.
Changed
-
The release notes now open with the one-line setup call and, below it, the
curlcalls plus the checksum check — instead of starting attar -xzfand quietly assuming the archive is already on the server. How the package gets there was the one step nobody had written down:INSTALL.mdsaid "unpack the package on the server" and then only showedgit clone. -
INSTALL.md, section 1 is now "Dateien auf den Server bringen" and names three ways in order of preference: the setup script, the manualcurlfrom the package registry with the checksum check, andgit clonefor development. The chapter "Aktualisieren" leads with the same one-line call — an operator who jumps straight to that chapter would otherwise never learn the script exists. -
RELEASE.mdgained two steps before the tag: 2b names, file by file, every place that spells out a version in prose, and 2c dry-runs the release note, the package self-check and the extraction of the previous version.
3.0.1 — 2026-08-19
Fixed
-
Password-protected download links did not work at all — they answered a server error instead of asking for the password. This affected every protected link, on the landing page and on the file itself, in every version up to and including 3.0.0. A link without a password was never affected, which is why this went unnoticed for so long: you try your own links, and usually without a password.
What this means for you: nothing is lost and no link has to be re-created. The links, their passwords, their expiry dates and their counters were always intact — only the page refused to render. After the update every protected link works as it always should have. If you handed one out and the recipient never came back to you, this is very likely why — it is worth telling them to try again.
Cause, for the record: the code asked the session store for a value using a method that exists on the request object but not on the session store. The call raised an exception, and because of how the surrounding condition was written it could only ever be reached when a password was set. The test suite had the matching gap — no test requested the public page of a protected link. That path is now covered from the outside.
-
The installer checked the wrong file when it verified that it can write the
.env. It always looked at the.envinside the project directory, even on installations that keep theirs somewhere else. Those got a green tick for a file nobody writes to. It now checks the file it will actually write. -
The installer and Settings → System no longer trust
is_writable()for directories. That function compares permission bits against the effective user id and misses ACLs, NFSroot_squash, read-only mounts and SELinux — it reports "writable" while writing fails anyway. Both now try to write a file and remove it again. On the diagnostics page in particular, a wrong "writable" is the worst possible answer: it sends you looking in the wrong place.
Changed
-
The seeder now reads its credentials from the configuration instead of the environment directly. With a cached configuration the old code silently created the account with the built-in defaults rather than the values that were set.
Only relevant for development and CI. A production installation creates its first administrator through the installer and never runs
php artisan db:seed. If you do use the seeder, the two variables are now calledFILESHARE_SEED_ADMIN_EMAILandFILESHARE_SEED_ADMIN_PASSWORD(previouslyADMIN_EMAILandADMIN_PASSWORD) and are documented in.env.example. The old names are ignored — rename them, or you get the defaults. -
The unused
s3disk is gone fromconfig/filesystems.php. The required package was never installed, so selecting that disk would have failed at runtime; meanwhile the block read sevenAWS_*variables that appear nowhere in.env.example, sending anyone who reads the configuration looking for a bucket that does not exist. Nothing to do — if you ever want S3, install the package and put the disk back.
Notes
fileshare:cleanupkeeps its--chunk-age=3default. A default value in a command signature is a problem where the command would otherwise ask for the value interactively; this one asks nothing, and removing the default would make the option mandatory and break every existing cron entry.ses(config/services.php) andbeanstalkd(config/queue.php) also read variables for packages that are not installed. Unlike thes3disk they sit behind an explicit driver choice that the application restricts tosmtpandsendmail, so they cannot be reached by accident. They are Laravel's own defaults and stay, so a framework upgrade stays readable.- A note for anyone changing
AccountPurge: deleting an account removes its share links with a single query, which is correct today because the database foreign keys carry the logs along. Adding soft deletes or a model event toShareLinkwould silently break that. The reasoning is now a comment in the file.
3.0.0 — 2026-08-19
Changed
- The update check is on by default now. Up to and including 2.1.0 the shipped default
was
false, so an installation nobody reconfigured stayed quiet. Those are precisely the installations that go on running an outdated version longest — not out of a decision, but because nobody is watching. A file service on the open internet serving a version with a known hole is the worse of the two outcomes. What leaves the machine is one GET request per day toFILESHARE_UPDATE_SOURCE, made when someone opens Settings → System: IP address and time of day. No identifier for the installation, no usage data, no report of what was found. Nothing is installed by it — the page shows a notice and the command - To switch it off:
FILESHARE_UPDATE_CHECK=falsein the.env, thenphp artisan config:cache. If that key is already present — it is on every installation set up with the installer — your setting stands and nothing changes for you. The default only governs installations that never made a decision, which is also why this is a major version and not a patch note
2.1.0 — 2026-08-19
Added
update.sh— updating an existing installation is now one command. The archive always worked for updates, but the only instruction was "unpack the new package", and unpacking over a live installation goes wrong in three ways, none of which announces itself: removed files stay behind (an update adds and overwrites, it never deletes, so retired classes, templates andconfig/files keep being loaded), thestorage/andbootstrap/cache/trees shipped for fresh installs reset the permissions of the live ones (and the owner too, if unpacked as root), and the cached configuration from the previous version survives. A list of commands in a manual does not fix that — the order is the hard part, and a hardcoded/var/www/...is wrong on most hosting. The script therefore runs from the unpacked package and takes the target path as an argument- Before it touches anything, the script proves the target is a Fileshare installation
(
artisan,composer.json,app/Support/Installer.php,.env). It is about to synchronise with--delete; a mistyped path would otherwise empty a stranger's directory - It backs up database,
.envand code to../fileshare-backup-<timestamp>/and aborts if that fails, before anything is modified. A script that only recommends a backup does not have one.--skip-backupoverrides it - Protected from replacement and deletion:
.env,storage/,public/storage,bootstrap/cache/andvendor/. The last one was found by a dry run: deletingvendor/first and reinstalling after means a failedcomposer installleaves neither the old nor the new state - Works without rsync — not every hosting has it. The fallback copies and then removes what is not in the package's own file list, which is exactly the part plain unpacking omits. Both paths were verified to produce the identical result
- If it fails after the file swap, the installation deliberately stays in maintenance mode and the script prints how to roll back. Serving a half-swapped state is worse than a maintenance page
- A
VERSIONfile in the package and on deployed installations, so the script can report "1.0.0 -> 2.0.2" instead of guessing. Tagged builds carry the version, branch deployments the commit INSTALL.mdand the release notes lead with the script and keep the manual steps for anyone who prefers them- Settings → System now shows which version is installed and whether a newer one
exists. The most common reason a self-hosted application runs an outdated version is
not laziness, it is not knowing. The check compares the
VERSIONfile against the newest release tag, once a day, cached - It is off unless someone says otherwise. The shipped default is
false, and that is the actual point of the key: it governs installations that never made a decision — every existing one after an update. Withtruethere, they would start reaching out on their own. An application that otherwise never phones home (noindex everywhere, no tracking pixel, no statistics) must not begin to do so quietly. Whoever runs the installer is asked and gets an explicit value written into the.env; there the question is pre-filled with yes, because someone is deciding right then - It can be switched off (
FILESHARE_UPDATE_CHECK=false), and that is not decoration: the request tells the server behindFILESHARE_UPDATE_SOURCEthat this installation exists, along with its IP and the time of day. Inside a closed network it is a timeout anyway. Whether to ask is the operator's decision, not the product's.FILESHARE_UPDATE_SOURCEalso lets a fork point the check at its own repository - The check never blocks a page: a five-second timeout, and any failure — no network, broken JSON, changed API — results in no notice rather than an error. A page of operating values must not fail because someone else's server is slow
- The newest version is determined by comparing, not by taking the first entry. The API sorts by date, so a release created later for an older version would otherwise prompt an "update" to a version going backwards
- A button stages the release package: it downloads the archive, verifies it against
the published SHA256 and unpacks it into
storage/app/private/updates/— a directory that is writable anyway. Then it shows the one command left to run, with the path of this installation already filled in - It deliberately stops there. Applying the update from the browser would require the web server process to overwrite the very code it executes, which turns every file-write flaw anywhere in the stack into code execution. That is a steep price for saving one terminal command, and the whole posture of this application argues against it. The interface takes the fiddly part, the command line keeps the dangerous one
- Without the checksum comparison the download would be a way to place arbitrary code on
the server, so a release without a published checksum is refused before anything is
fetched, and a mismatch discards the archive. The version string is validated against
^\d+\.\d+\.\d+$before it is used — it becomes part of a path - The installer asks about it, rather than leaving it to be switched off afterwards.
The command line takes
--update-check=true|falseand--update-source=…(the source is only asked for when checking is on — otherwise it is a question about the address of a server that will never be called), and writes both to the.env. The browser installer offers the checkbox only: the source is an expert setting that would raise more questions on that page than it answers. A decision that opens a connection to the outside belongs in the installation, not in a setting someone has to find later --update-checkis a value option, not a flag: a flag can only switch on, and an automated deployment has to be able to state both without relying on the default- Settings → System says where the two switches live (
FILESHARE_UPDATE_CHECK,FILESHARE_UPDATE_SOURCE) and thatconfig:cachehas to run afterwards. They are deliberately not editable through the interface — they decide about a connection to the outside, and that is a server decision, not one that should come out of a browser form. But then the page has to say where to find them, or the operator goes looking in a manual that is not open EnvFileTestcovers the boolean round trip: afalsewritten by the installer has to read back asfalse. Written as the string"false"in a way phpdotenv reads as true, a check switched off during installation would quietly keep runningRELEASE.md— how a release is cut and published. Pick the version, close the CHANGELOG section, push, wait for green, tag. Plus what the pipeline does at each stage, what to check afterwards, and the three failure modes that have actually occurred: a missing CHANGELOG section, a tag that already exists, and a package upload refused because the registry is switched off- The pipeline refuses to publish a release whose version has no CHANGELOG section. The notes are generated from exactly that section; without it the release page says nothing about what changed — which is what happened with 2.0.1 and was only noticed on the finished page. The check runs before anything is built or uploaded, so nothing is published and the tag can be taken back with three commands, which the error message spells out. Deliberately an abort rather than a warning: five minutes later the package is in the registry and the release exists
VERSIONis in.gitignoreand must not be committed. It is a build artefact — the pipeline writes the tag into it when packaging and the tag ormain@<sha>when deploying. Committed it would go stale and tell everygit clonea version that is not true, and a wrong answer there is worse than none: the "unknown" case is handled everywhere, a wrong one is notfileshare:cleanupremoves staged packages once they have been applied. The criterion is the version, not the age: anything not newer than the installed one is done. Without aVERSIONfile it leaves them alone — there is no yardstick then, and guessing here would mean deleting- The script speaks English — it runs in a terminal, and everything a terminal prints
is read by whoever deploys. Its comments stay German, like everywhere else.
CONVENTIONS.mdnow names the boundary explicitly: the handbooks underdocs/andINSTALL.mdare the deliberate exception on the other side — continuous prose for operators, German alongside the interface they describe — while anything a program prints is English
2.0.1 — 2026-08-18
Fixed
- Release notes lost every word written in backticks. GitLab passes
release:descriptionthrough the shell, where a backtick is command substitution — so`composer install`was replaced by that command's (empty) output, and the published notes read "then , and .". A fenced code block would have failed the same way. The notes are now generated as a file and referenced by path, which release-cli reads verbatim. Side effect worth having: the matching CHANGELOG section is extracted and appended, so a release page shows what changed instead of linking to it
Changed
- The pipeline's own output is English, like everything else developers read — variable check, SSH key check, target-server preflight, package self-check and the call to action for a fresh server. Comments in the YAML stay German
- The language rule in
CONVENTIONS.mdsays more precisely who counts as a user: only the people who use the application. Whoever deploys, installs or reads a release note is a developer, even when that is the same person. Rule of thumb: browser or terminal?
2.0.0 — 2026-08-18
Added
-
GitLab CI/CD pipeline (
.gitlab-ci.yml): stagestest,buildanddeploy, withresource_group: production, maintenance mode,rsyncguarded by.rsync-exclude, production Composer install, migrations, cache warm-up andqueue:restart -
Default logo and favicon; GitLab mark
-
phpMyAdmin as a DDEV add-on for local development
-
EnvExampleTestkeeps.env.exampleand the code in step: everyFILESHARE_*key read inconfig/is documented, no invented keys are documented, the defaults match, and no unquoted value contains whitespace -
An installation process, on two equal footings.
php artisan fileshare:install(interactive, or driven entirely by options for automated deployment) and a browser installer at/install. Both are thin front ends over oneApp\Support\Installer, so neither can quietly set up something the other forgets. Steps: requirements, database,.env, migrations, first administrator,public/storage, finish. The command line speaks English — it is used by developers and administrators, where English is expected and error messages are searchable. The browser installer speaks German like the rest of the interface. Checks therefore carry machine-readable keys (php,ext.pdo_mysql,writable.storage, …) and each surface supplies its own wording -
Interactive prompts are pre-filled from the existing
.env, so re-running the installer against an existing configuration means confirming rather than retyping -
The test suite no longer touches the development
.env.phpunit.xmlsetsAPP_ENV=testing, and Laravel then looks for.env.testingbefore falling back to.env. That fallback meant two things: on a machine without a.envevery single test reported the missing file, and — worse — values from a developer's own.envleaked into the run, so a test could behave slightly differently for each person. A committed, deliberately empty.env.testingstops the fallback;phpunit.xmlis now the single source. It also carries a fixedAPP_KEY, without which every test that touches a session or cookie fails withMissingAppKeyException— which is exactly what the CI pipeline was hitting on its fresh checkout -
The test suite no longer requires a frontend build.
{{ vite(...) }}readspublic/build/manifest.json, so without it every test that renders a full page failed with 500 — invisible locally, where the build exists, but not in CI, where the test stage runs before the build stage and is meant to need no node at all.$this->withoutVite()in the shared base class swaps in an empty handler; whether a build exists at all is the build stage's job, and the installer's requirement check reports it at runtime -
EnvExampleTestparses the shipped.env.examplewith an array-backed reader and asserts it loads. A syntax error in that file would otherwise surface only for the operator who copies it to.env— whose fresh installation then refuses to start -
The deployment pipeline handles a target that has never been installed. It asks the application itself —
php artisan fileshare:installed, exit code 0 or 1 — because from the outside there is no reliable answer: an existing.envsays nothing about whether anything was ever migrated. On a fresh target the job transfers the files, runscomposer installand then stops with instructions for both installation paths, marked as a warning rather than a failure. It deliberately skips migrations and cache warming there: the former would have no database, and a cached config would freeze the.envthe installer is about to write -
The deployment checks the target before transferring anything: writable directory,
php(8.3 or newer) andcomposeron the PATH. Anrsynconto a server that cannot run the application leaves a half-finished installation behind -
The completion page of the browser installer no longer answers 404. The session store was switched to files only while no installation was detected — but the completion page is by definition reached after one exists, so that one request read a database session and could not find the proof written into the file session a moment earlier. The switch now covers the whole installation route
-
php artisan fileshare:installasks for the administrator password twice, as the browser installer already did. A hidden field shows no typo, and for this one account there is no way back until mail delivery is configured — a mistyped password would mean installing again -
The browser installer is reachable before any configuration exists. Without an
APP_KEYLaravel cannot build its encrypter, so the session cookie fails and the request dies with a bare 500 that says nothing about the missing key./installnow creates the.envfrom the example and generates a key on first call — the smallest possible side effect, and only while no installation is detected. If the directory is not writable, a key valid for that one request lets the page render and report what is actually wrong -
Any
.envwritten by the installer starts from.env.example. Setting a single key on a missing file used to produce a.envcontaining only that key -
Settings::set()no longer fails when the cache cannot be reached. Writing the value is the operation and is allowed to fail loudly; discarding the cached copy afterwards is housekeeping. The installer hit exactly this: everything was set up, and the very last step — recording that the installation had finished — tried to invalidate a cache entry in a database the process had resolved before the.envexisted -
DB_PREFIXsupport, so the application can share a database with others — the normal case on hosts that only offer one. The installer therefore does not require an empty database, and does not create one either: on shared hosting the application user rarely has the rights, and an installer that asks for root credentials is a liability -
INSTALL.md— one page, one recommended path, with the prerequisites stated before the first command and a symptom table at the end -
EnvFileTestcovers the.envwriter, above all its quoting: the installer accepts passwords, and phpdotenv truncates an unquoted value at the first# -
An operator-supplied theme. Settings → Appearance offers the shipped
resources/css/tokens.cssas a download, takes an edited copy back, and injects its contents as a<style>block in the<head>of every page. No rebuild is needed because@theme inlinemakes the generated Tailwind utilities referencevar(--c-…)rather than fixed colour values, so a:rootblock loaded at runtime overrides all of them at once. The file lives instorage/app/private/theme/tokens.css—storage/is in.rsync-exclude, so a deployment does not overwrite it. The download is the real, commented file rather than a stripped-down template: the comments are what explain which variable does what. There is deliberately no colour picker — thirty colour fields would carry less meaning and do less -
Uploaded themes are checked for extension only, and that as protection against picking the wrong file, not as a security measure. The routes to code execution from CSS are closed (
expression()was IE-only, Firefox dropped-moz-bindingin 2019, browsers blockjavascript:inurl()), and whoever uploads here is an administrator who could already publish HTML in editorial pages. A MIME check was tried and removed: libmagic reports CSS astext/plain,text/x-cortext/x-asmdepending on content, so the rule would have rejected valid themes while protecting nothing -
External references in an uploaded theme (
@import, absoluteurl()) are reported, not rejected — with the offending lines. They are a data-protection question rather than a security one, and they hit someone other than the uploader: every visitor's IP address goes to a third-party server on every page view, and the operator is the one answerable for it. The likely case is not a malicious administrator but a theme copied from the web with a font-CDN@importin its first line. Comments,data:URLs and project-relative paths deliberately raise no alarm — a warning that appears for every theme is read for none -
Font files are not uploadable through the interface. They are placed in
public/fonts/by hand and referenced from the uploadedtokens.css. Web fonts are licensed; whoever installs one needs to hold that licence, and that is a decision at the server, not in a browser form -
ThemeUploadTestcovers the parts that would otherwise break silently: that the contents actually reach the rendered page, that the external-reference detector does not produce false alarms, and that only administrators reach the area -
The downloaded skeleton is rewritten so that every line in it actually works.
resources/css/tokens.cssis a source file for the Tailwind build, not a file for a browser: what sits in its@themeblocks is an instruction to the build. A browser does not know that at-rule and discards it together with its contents — silently, with nothing in the console. Handed over unchanged, the template was therefore half a facade: change--c-inkand it works, change--font-sans,--radius-mdor--color-dangerin the same file and nothing happens. The download now turns@theme { … }into:root { … }(the built stylesheet puts exactly those variables in:root, and the utilities point at them viavar()) and drops@theme inlineentirely, because that block's wiring is already compiled in —text-inkhas becomecolor: var(--c-ink), so the--c-…values are the knobs -
An uploaded file containing a
@themeblock is reported line by line — same treatment as external references: the file is not broken, it is only partly inert, and the person who uploaded it has no other way of finding out. A mention of@themein a comment is deliberately not a finding; only an opening brace makes it a block -
A send log for share links (
share_link_sends): recipient, sender, time and outcome of every mail the system sends for a link. The failed send is the reason this table exists — until now that information lived only in a flash message, and whoever dismissed it could no longer find out whether the file had ever gone out. The record is written on success and on failure, with the mail server's own message kept verbatim (truncated to 1000 characters); a summary like "sending failed" helps nobody looking for a cause -
A timeline on the link detail page and in the drawer, merging sends and downloads into one chronological list. Merged rather than side by side because the user's question is not "how often was it downloaded" but "does the recipient have the file", and only the order answers that: sent Tuesday, downloaded Tuesday — done. Two separate lists move that connection into the reader's head
-
The timeline states plainly what "sent" means: the mail server accepted the message. Whether it reached the recipient's mailbox is something this installation cannot know without tracking pixels or provider webhooks, and it has neither by decision. A non-delivery is reported to the envelope sender — the system address from
MAIL_FROM_ADDRESS— not to the user, which is now stated in the administrator's manual as an operational duty: someone has to read that mailbox -
The timeline deliberately omits the downloader's IP address. It is in the download log for operational reasons; showing it here would hand a third party's personal data to a user who does not need it for the question being asked
-
FILESHARE_SEND_LOG_RETENTION_DAYS(default 90,-1= never) and a new category infileshare:cleanup. The recipient address is personal data. The clean-up report lists time and link only, never the address — the report ends up in terminals, cron mails and the admin panel, and should not distribute a list of third-party addresses as a side effect of the deletion meant to remove them -
Below the timeline the interface names the retention period in force, and takes the shorter of the two (send log, download log): as soon as one of them bites, the merged list is incomplete. Without that sentence an emptied timeline reads as "never downloaded" — the wrong answer to the only question asked here
-
ShareSendLogTestcovers the failure path, the merge order, the absence of the IP address, the cascade on link deletion, and that the clean-up removes only entries past the deadline -
php artisan fileshare:check— the installer's prerequisite check as a standalone, read-only command, so the deployment can run the same check the installer runs. It is called after the transfer and beforemigrate,config:cacheandview:cache, because those are exactly what fails when permissions are wrong: mid-deployment, with the application in maintenance mode and half-written caches, reportingfailed to open stream: Permission deniedfor one file and nothing about the cause. It cannot run before the transfer — on an empty target the directories do not exist yet -
The permission check now covers each runtime directory individually, not just
storage/. A writablestorage/says nothing aboutstorage/framework/views, and that is where it surfaces: as a 500 on the first page view, after a green pipeline.framework/cacheandframework/sessionsare included even when cache and sessions live in the database — during installation there are no tables yet, so the installer switches both to files -
Writability is established with an actual write attempt instead of
is_writable(). That function compares permission bits against the effective user id and walks straight past ACLs,root_squashon NFS, read-only mounts and SELinux: it reportstruewhile writing still fails. Creating a file and removing it again is the only answer that holds -
Missing and non-writable directories are reported as two separate checks, because they call for two different commands (
mkdir -pversuschown/chmod). Both list the affected paths, project-relative, and the CLI prints the matching command ready to copy -
The deployment creates the runtime directories with
mkdir -pbefore checking. This is not fixing someone else's permissions:storage/is in.rsync-excludeso that a deployment does not overwrite uploaded files — on a fresh server that same exclusion means the tree never arrives -
New check: the
.envmust not be world-readable. It holds the database password,APP_KEYand the file encryption key; on shared hosting0644means every other customer on the machine can read it. Reported as advice, not an obstacle — whether it matters depends on who else is on that machine, and the operator knows that, the installer does not. It therefore does not affect the exit code either: letting a deployment fail over a recommendation only teaches people to skip the check -
New check:
artisanpresent and readable. Without it there are no cron jobs, no queue worker and no clean-up run
Changed
-
One language rule, written down and applied: English for developers, German for users. The line runs between audiences, not between code and text. English: identifiers of every kind, test methods and data providers, Twig
{% set %}variables, array keys, Artisan output and option descriptions. German: everything in the interface, validation messages, mails, manuals. Code comments and PHPDoc stay German — the one deliberate exception, because a comment justifies a decision, and a justification in the second language loses the nuance it was written for. The rule now sits inCONVENTIONS.mdwith that reasoning -
Renamed accordingly: 231 occurrences of ~50 German variable names across 13 files, all 70 German test method names, the
wertedata provider and its case labels, and three Twig variables in the installer layout. Method and class names were already English. Verified after the fact that nothing German remains and that every file still parses -
fileshare:cleanupspeaks English, like every other command. That needed a structural change rather than a translation:StorageCleanupfeeds both the command and the maintenance page in the admin panel, so its Germanlabelcannot simply be replaced. Each category now also carries machine-readablecontext(retention days, hours, skipped items) next to itskey, and the command composes its own English text from those. The same split as the prerequisite check — one piece of logic, two wordings. Without it the command would have to parse numbers back out of a German sentence -
.rsync-excludenarrowed to what the running application needs. Previously the deployment shipped the whole working tree minus a handful of directories; the server now receives no source CSS or JS (only the builtpublic/build/), no tests, no documentation, no build tooling, no editor or DDEV configuration, and no development SQLite file. Three of the additions are not tidiness but hazards removed:/bootstrap/cache/— a locally builtconfig.phptravelling to the server would silently override the whole production.env;/tests/plusphpunit.xml— onephp artisan testin the wrong directory runsRefreshDatabase, and that meansmigrate:fresh;/public/storage— the symlink is not in the repository, so--deleteremoved it on every deployment and the logo was dead untilstorage:linkran again -
resources/css/tokens.cssis the one deliberate exception in that file, because it is read at runtime:Theme::skeleton()builds the downloadable template from it. It needs an include rule before the exclude, and the exclude has to target the directory contents (/resources/css/**) rather than the directory — rsync never enters an excluded directory, so an exception inside one never applies. Both the rule and the reason are written into the file; this is exactly the kind of thing that gets "cleaned up" later -
What stays is documented in the same file, with reasons:
.env.example(the installer writes the.envfrom it),database/factoriesandseeders(listed in the Composer autoloader),INSTALL.md(the pipeline points at it by name when a fresh system needs setting up) andLICENSE.md -
Note when updating an existing target:
--deletedoes not remove what is excluded — excluded paths are protected. Files a previous deployment left behind (tests/,resources/js/,docs/, …) therefore stay and have to be removed once by hand.--delete-excludedis the wrong tool: it would take.envandstorage/with it -
Search engine exclusion enforced for every response, not just pages using the layout: global
NoIndexmiddleware settingX-Robots-Tag, covered byNoIndexTest -
The contact route is gone. The BFSG feedback channel is no longer a hard-wired
/kontaktform but an editorial page the operator creates in the admin panel. Amailto:link satisfies the requirement; a form was never mandated.kontaktis no longer a reserved slug, so the page can actually be created -
Editorial content no longer receives a forced
target="_blank"on every link. HTMLPurifier applied it tomailto:links as well, and WCAG 3.2.5 asks for a warning before a new window opens — an automatic attribute cannot provide one -
UploadFinalizer::store()now takes a typedShareLinkOptionsvalue instead of an untypedarray $meta. The expiry it receives is a point in time; translating the form value happens in the controller that validated the form. This is the structural fix behind the expiry bug below — an array could not state what belonged in it -
.env.examplerewritten. It is the only instruction sheet an operator gets, and it had drifted: it advertised Redis, Memcached and S3 although none of those packages are installed, defaultedDB_CONNECTIONtosqlitefor a product that runs on MySQL/MariaDB, and omittedSESSION_SECURE_COOKIEentirely — so a production install would silently serve its session cookie over plain HTTP unless the operator knew to add it. Now grouped by topic, with the four mandatory values first, one line of explanation per key, and every value that may contain special characters quoted -
The application's own upload limit is now
20971520KB (20 GB) in both.env.exampleandconfig/fileshare.php, and the delivery method defaults toautoin both. The two had disagreed, so an installation without the entry behaved differently from what the example promised.FILESHARE_MAX_UPLOAD_KBis the application's limit — chunked uploads bypass PHP'supload_max_filesizeandpost_max_size, but not this one, so a low value rejects large files on every path
Fixed
- Files deposited through an upload link ignored the link's own expiry
(
file_expires_days) and fell back to the default:UploadFinalizeronly readexpires_days, while the external path passesexpires_at sendmailtransport path resolution (MailConfig,config/mail.php)composer.lockout of sync withcomposer.json, which brokecomposer validate --strict- The skeleton
ExampleTestasserted that/returns 200./is the upload area and sits behind authentication, so the test had been failing permanently — and with it theteststage of the pipeline. It now asserts the redirect to the login page - The application could not boot without a database.
AppServiceProvider::boot()callsBranding::apply(), which reads thesettingstable on every start — includingartisan package:discover, which Composer runs after everyinstall. On a fresh environment the table does not exist yet, so the boot aborted and tookcomposer installdown with it.Settings::get()now falls back to the supplied default when the query fails, so the application starts without a database. This is a prerequisite for the installer: it cannot set up a database from inside an application that refuses to start without one - A cached configuration made the test suite operate on the development database.
With
bootstrap/cache/config.phppresent, Laravel ignores every<env>value inphpunit.xml— they only ever reach the app throughenv(), which a cached config never consults. The environment stayedlocal(so CSRF was enforced and every POST returned 419) anddatabase.defaultpointed at the development database, whichRefreshDatabasethen wiped withmigrate:fresh. Three separate guards now:phpunit.xmlpointsAPP_CONFIG_CACHEandAPP_ROUTES_CACHEat non-existent paths so no cache can apply under phpunit;Tests\TestCaseaborts with an explanatory message if the environment is nottestingor the database is not:memory:; andDB::prohibitDestructiveCommands()blocksmigrate:fresh,migrate:refreshanddb:wipeoutsidelocalandtesting
Security
migrate:fresh,migrate:refreshanddb:wipeare refused outside thelocalandtestingenvironments. Deliberately not tied toisProduction(): an installation whose operator never setAPP_ENVwould be the one left unprotected, and that is where the damage is most likely. The system data page now warns when a running installation is still onAPP_ENV=local
Removed
FILESHARE_CONTACT_EMAIL— the contact form it configured no longer exists
1.0.0 — 2026-08-15
First public release. Fileshare is functionally complete, documented for operators and users, and licensed under the PolyForm Noncommercial License 1.0.0.
Added
Sharing files
- Upload a file and generate an unguessable download link (
/d/{token}) - Optional expiry date, password protection and download limit per link
- "No expiry" links, if the operator allows them
- Chunked uploads via Dropzone and
pion/laravel-chunk-upload(5 MiB chunks) with progress display; bypassesupload_max_filesize,post_max_sizeandmax_execution_time - Classic multipart upload as a fallback without JavaScript
- Drop a file anywhere on the upload page, not just on the drop zone
- Send a download link from the system by email
- Download log per link, with configurable retention
Encryption
- Optional at-rest encryption with AES-256-GCM, per file
- Encryption and decryption of existing files as a background queue job, so large files do not hit the request timeout
- Key supplied via
FILESHARE_ENCRYPTION_KEY; without a key, encryption is disabled - Encrypted files are always streamed and decrypted through PHP
Upload links (inbound)
- Signed-in users can create links (
/u/{token}) that let outsiders without an account deposit files for them - Mandatory expiry, selectable upload count (1/3/10/unlimited), optional password
- Counter reserved under a database lock so parallel uploads cannot exceed the limit
- Throttling and a honeypot on all public routes
- Files from external uploads are marked as such and are never publicly retrievable — retrieval requires sign-in
- Uploader IP stored for abuse prevention, automatically removed after a configurable retention period
Accounts and administration
- No public registration; the seeder creates a single
adminaccount - Administrators create further accounts, which receive an email invitation and set their own password
- Roles
adminanduser; the last remaining administrator cannot be demoted or deleted - Two-factor authentication (TOTP) for sign-in, with QR code enrolment
- Self-service account deletion including all own files and links
- Password reset and confirmed email address changes
Administration panel (/admin/settings)
- General: installation name and logo for header and system mails
- Features: four system-wide switches (offer encryption, allow upload links, offer "no expiry", scope of retrieval for externally uploaded files)
- Email: SMTP or sendmail, sender address, test mail; settings take precedence over
.env, SMTP password stored encrypted with theAPP_KEY - Delivery: download method with self-test that detects silently broken acceleration
- Maintenance: find and remove orphaned files, records and chunk remnants
- System data: operating values, diagnostics and a copyable plain-text report containing no credentials or keys
Content and public pages
- Editable footer pages (legal notice, privacy policy, accessibility statement, …) with a rich text editor
- Contact form with a configurable target address, doubling as the BFSG feedback route
Delivery and performance
- Download method
auto/php/xsendfile/x-accelfor unencrypted files - Accelerated delivery only used after a passed self-test, otherwise PHP streaming
- Files always delivered as an attachment, never inline
Operations
php artisan fileshare:cleanupwith a dry run by default and--forceto act- Same cleanup available in the admin panel with a report
- DDEV setup for local development, including an
X-Accelnginx configuration
Accessibility
- Interface built against the German BFSG / WCAG 2.1 AA from the start rather than
audited afterwards; see
ACCESSIBILITY.md - Accessibility statement and a reachable feedback route as public artifacts
Documentation
- Administrator handbook and user handbook (German)
- Frontend conventions (
CONVENTIONS.md) and accessibility documentation - English README, German README retained as
README.de.md
Licensing
- PolyForm Noncommercial License 1.0.0
- Contributor License Agreements for individuals and companies, German (binding) and English (translation)
- Licensing FAQ and contribution guide
- License headers in all PHP, CSS and JavaScript source files
Security
- No public registration; passwords are never handed over manually
- Two-factor authentication available for all accounts
- SMTP password encrypted at rest with the
APP_KEY - Throttling and honeypot on all public routes
- Externally uploaded files are quarantined behind authentication
- Diagnostics report deliberately excludes credentials and keys
Known limitations
- No virus scanning. Content is not inspected for malware. Files are delivered as attachments only and their origin is marked, but scanning remains the recipient's responsibility.
- A queue worker is required for changing the encryption state of a file. Without one, such a request stays pending indefinitely. Upload, download and link management work without a worker.
- Loss of
FILESHARE_ENCRYPTION_KEYis unrecoverable. Encrypted files cannot be restored without it. - One file per download link.
- Operator handbooks are available in German only.
Version links
Quelle dieser Seite:
CHANGELOG.md
im Repository. Sie wird beim Bauen daraus erzeugt — Änderungen gehören in die
Markdown-Datei, nicht in diese Seite.