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 said mail.beispiel.de, the share dialog name@beispiel.de. Nobody sends mail to a placeholder, so nothing was at risk — but it is the same rule as the one .env.example follows 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 gd extension. fileshare:check lists 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.example uses example.org instead of beispiel.de. Both read as placeholders and beispiel.de is in practice kept as one, but it is a real registration held by a private company — whose terms can change. example.org is 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, and MAIL_FROM_ADDRESS, which receives the bounces.

    Existing installations are unaffected.env is never overwritten by an update. The value applies to new installations only.

  • RELEASE.md gained 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 the pages job from site/:

    • 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, lastmod from 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; and pages.publish is set to site/dist — without it GitLab would publish public/, which in a Laravel project is the application's web root.

Changed

  • The documentation is now one page per topic. docs/ADMIN-HANDBUCH.md and docs/BENUTZERHANDBUCH.md were two files of 944 and 405 lines with their own tables of contents in the body text; they are now 35 pages under docs/, 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 a README.md that says which page replaced which chapter. They are no longer maintained, they are not in SEITEN, 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.md is 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 of update.sh, the reasoning about the setup script's checksum) now lives once, in docs/, and INSTALL.md links there.

  • The READMEs point at the published documentation instead of at the two handbooks.

  • composer.json names its homepage and its support links (docs, source, issues) — what composer show and 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-face block only offered the browser a file to fetch. The .woff2 stays under public/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-body is the body text, --e-heading the headings, --e-label the field labels, --e-card-line the card border, --e-button the button surface, --e-nav the header bar, --e-table-head the table head, --e-input-line the 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-ink and 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 .dark if 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-line and --e-dropzone-active (the surface while a file hangs over it) carry the large area of the upload pages; --e-progress carries the bar, --e-track the 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-hover is 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-ink used to be both, so a grey border with black text was not expressible. The new set is --e-input, --e-on-input, --e-input-line and --e-input-hint (placeholder), plus --e-input-check for 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 in tokens.css are balanced, and that no template reaches back to the palette. npm run build reports 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-ink became text-body, text-heading, text-label, text-help, text-on-table, text-on-nav, text-on-menu and so on, depending on what the text actually is; border-ink became border-card-line, border-table-line or border-dropzone-line; bg-brand became bg-button, bg-accent, bg-progress or 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.css so that a theme uploaded under 3.1 keeps working, including one that sets --color-field, --color-field-ink, --color-field-muted, --color-on-overlay or --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.md still named 3.0.1 after 3.1.0 shipped. Nothing breaks from that — the sentence just starts lying, which is why RELEASE.md lists 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 unless dark:prose-invert is set — and it was not. Body text on pages from the database rendered at oklch(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-neutral was 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.sh from 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:install and 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 curl calls plus the checksum check — instead of starting at tar -xzf and quietly assuming the archive is already on the server. How the package gets there was the one step nobody had written down: INSTALL.md said "unpack the package on the server" and then only showed git clone.

  • INSTALL.md, section 1 is now "Dateien auf den Server bringen" and names three ways in order of preference: the setup script, the manual curl from the package registry with the checksum check, and git clone for 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.md gained 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 .env inside 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, NFS root_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 called FILESHARE_SEED_ADMIN_EMAIL and FILESHARE_SEED_ADMIN_PASSWORD (previously ADMIN_EMAIL and ADMIN_PASSWORD) and are documented in .env.example. The old names are ignored — rename them, or you get the defaults.

  • The unused s3 disk is gone from config/filesystems.php. The required package was never installed, so selecting that disk would have failed at runtime; meanwhile the block read seven AWS_* 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:cleanup keeps its --chunk-age=3 default. 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) and beanstalkd (config/queue.php) also read variables for packages that are not installed. Unlike the s3 disk they sit behind an explicit driver choice that the application restricts to smtp and sendmail, 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 to ShareLink would 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 to FILESHARE_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=false in the .env, then php 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 and config/ files keep being loaded), the storage/ and bootstrap/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, .env and 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-backup overrides it
  • Protected from replacement and deletion: .env, storage/, public/storage, bootstrap/cache/ and vendor/. The last one was found by a dry run: deleting vendor/ first and reinstalling after means a failed composer install leaves 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 VERSION file 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.md and 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 VERSION file 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. With true there, 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 behind FILESHARE_UPDATE_SOURCE that 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_SOURCE also 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|false and --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-check is 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 that config:cache has 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
  • EnvFileTest covers the boolean round trip: a false written by the installer has to read back as false. Written as the string "false" in a way phpdotenv reads as true, a check switched off during installation would quietly keep running
  • RELEASE.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
  • VERSION is in .gitignore and must not be committed. It is a build artefact — the pipeline writes the tag into it when packaging and the tag or main@<sha> when deploying. Committed it would go stale and tell every git clone a version that is not true, and a wrong answer there is worse than none: the "unknown" case is handled everywhere, a wrong one is not
  • fileshare:cleanup removes 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 a VERSION file 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.md now names the boundary explicitly: the handbooks under docs/ and INSTALL.md are 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:description through 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.md says 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): stages test, build and deploy, with resource_group: production, maintenance mode, rsync guarded by .rsync-exclude, production Composer install, migrations, cache warm-up and queue:restart

  • Default logo and favicon; GitLab mark

  • phpMyAdmin as a DDEV add-on for local development

  • EnvExampleTest keeps .env.example and the code in step: every FILESHARE_* key read in config/ 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 one App\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.xml sets APP_ENV=testing, and Laravel then looks for .env.testing before falling back to .env. That fallback meant two things: on a machine without a .env every single test reported the missing file, and — worse — values from a developer's own .env leaked into the run, so a test could behave slightly differently for each person. A committed, deliberately empty .env.testing stops the fallback; phpunit.xml is now the single source. It also carries a fixed APP_KEY, without which every test that touches a session or cookie fails with MissingAppKeyException — which is exactly what the CI pipeline was hitting on its fresh checkout

  • The test suite no longer requires a frontend build. {{ vite(...) }} reads public/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

  • EnvExampleTest parses the shipped .env.example with 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 .env says nothing about whether anything was ever migrated. On a fresh target the job transfers the files, runs composer install and 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 .env the installer is about to write

  • The deployment checks the target before transferring anything: writable directory, php (8.3 or newer) and composer on the PATH. An rsync onto 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:install asks 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_KEY Laravel cannot build its encrypter, so the session cookie fails and the request dies with a bare 500 that says nothing about the missing key. /install now creates the .env from 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 .env written by the installer starts from .env.example. Setting a single key on a missing file used to produce a .env containing 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 .env existed

  • DB_PREFIX support, 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

  • EnvFileTest covers the .env writer, 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.css as 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 inline makes the generated Tailwind utilities reference var(--c-…) rather than fixed colour values, so a :root block loaded at runtime overrides all of them at once. The file lives in storage/app/private/theme/tokens.cssstorage/ 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-binding in 2019, browsers block javascript: in url()), 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 as text/plain, text/x-c or text/x-asm depending on content, so the rule would have rejected valid themes while protecting nothing

  • External references in an uploaded theme (@import, absolute url()) 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 @import in 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 uploaded tokens.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

  • ThemeUploadTest covers 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.css is a source file for the Tailwind build, not a file for a browser: what sits in its @theme blocks 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-ink and it works, change --font-sans, --radius-md or --color-danger in 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 via var()) and drops @theme inline entirely, because that block's wiring is already compiled in — text-ink has become color: var(--c-ink), so the --c-… values are the knobs

  • An uploaded file containing a @theme block 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 @theme in 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 in fileshare: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

  • ShareSendLogTest covers 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 before migrate, config:cache and view:cache, because those are exactly what fails when permissions are wrong: mid-deployment, with the application in maintenance mode and half-written caches, reporting failed to open stream: Permission denied for 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 writable storage/ says nothing about storage/framework/views, and that is where it surfaces: as a 500 on the first page view, after a green pipeline. framework/cache and framework/sessions are 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_squash on NFS, read-only mounts and SELinux: it reports true while 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 -p versus chown/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 -p before checking. This is not fixing someone else's permissions: storage/ is in .rsync-exclude so that a deployment does not overwrite uploaded files — on a fresh server that same exclusion means the tree never arrives

  • New check: the .env must not be world-readable. It holds the database password, APP_KEY and the file encryption key; on shared hosting 0644 means 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: artisan present 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 in CONVENTIONS.md with that reasoning

  • Renamed accordingly: 231 occurrences of ~50 German variable names across 13 files, all 70 German test method names, the werte data 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:cleanup speaks English, like every other command. That needed a structural change rather than a translation: StorageCleanup feeds both the command and the maintenance page in the admin panel, so its German label cannot simply be replaced. Each category now also carries machine-readable context (retention days, hours, skipped items) next to its key, 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-exclude narrowed 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 built public/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 built config.php travelling to the server would silently override the whole production .env; /tests/ plus phpunit.xml — one php artisan test in the wrong directory runs RefreshDatabase, and that means migrate:fresh; /public/storage — the symlink is not in the repository, so --delete removed it on every deployment and the logo was dead until storage:link ran again

  • resources/css/tokens.css is 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 .env from it), database/factories and seeders (listed in the Composer autoloader), INSTALL.md (the pipeline points at it by name when a fresh system needs setting up) and LICENSE.md

  • Note when updating an existing target: --delete does 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-excluded is the wrong tool: it would take .env and storage/ with it

  • Search engine exclusion enforced for every response, not just pages using the layout: global NoIndex middleware setting X-Robots-Tag, covered by NoIndexTest

  • The contact route is gone. The BFSG feedback channel is no longer a hard-wired /kontakt form but an editorial page the operator creates in the admin panel. A mailto: link satisfies the requirement; a form was never mandated. kontakt is 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 to mailto: 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 typed ShareLinkOptions value instead of an untyped array $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.example rewritten. 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, defaulted DB_CONNECTION to sqlite for a product that runs on MySQL/MariaDB, and omitted SESSION_SECURE_COOKIE entirely — 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 20971520 KB (20 GB) in both .env.example and config/fileshare.php, and the delivery method defaults to auto in both. The two had disagreed, so an installation without the entry behaved differently from what the example promised. FILESHARE_MAX_UPLOAD_KB is the application's limit — chunked uploads bypass PHP's upload_max_filesize and post_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: UploadFinalizer only read expires_days, while the external path passes expires_at
  • sendmail transport path resolution (MailConfig, config/mail.php)
  • composer.lock out of sync with composer.json, which broke composer validate --strict
  • The skeleton ExampleTest asserted that / returns 200. / is the upload area and sits behind authentication, so the test had been failing permanently — and with it the test stage of the pipeline. It now asserts the redirect to the login page
  • The application could not boot without a database. AppServiceProvider::boot() calls Branding::apply(), which reads the settings table on every start — including artisan package:discover, which Composer runs after every install. On a fresh environment the table does not exist yet, so the boot aborted and took composer install down 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.php present, Laravel ignores every <env> value in phpunit.xml — they only ever reach the app through env(), which a cached config never consults. The environment stayed local (so CSRF was enforced and every POST returned 419) and database.default pointed at the development database, which RefreshDatabase then wiped with migrate:fresh. Three separate guards now: phpunit.xml points APP_CONFIG_CACHE and APP_ROUTES_CACHE at non-existent paths so no cache can apply under phpunit; Tests\TestCase aborts with an explanatory message if the environment is not testing or the database is not :memory:; and DB::prohibitDestructiveCommands() blocks migrate:fresh, migrate:refresh and db:wipe outside local and testing

Security

  • migrate:fresh, migrate:refresh and db:wipe are refused outside the local and testing environments. Deliberately not tied to isProduction(): an installation whose operator never set APP_ENV would 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 on APP_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; bypasses upload_max_filesize, post_max_size and max_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 admin account
  • Administrators create further accounts, which receive an email invitation and set their own password
  • Roles admin and user; 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 the APP_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-accel for 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:cleanup with a dry run by default and --force to act
  • Same cleanup available in the admin panel with a report
  • DDEV setup for local development, including an X-Accel nginx 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_KEY is unrecoverable. Encrypted files cannot be restored without it.
  • One file per download link.
  • Operator handbooks are available in German only.

Quelle dieser Seite: CHANGELOG.md im Repository. Sie wird beim Bauen daraus erzeugt — Änderungen gehören in die Markdown-Datei, nicht in diese Seite.