Zum Inhalt springen

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

5.0.2 — 2026-09-26

Updating is update.sh and nothing else. This release closes published security advisories in two dependencies. The PHP requirement now reads 8.4.1 — what every 5.0 installation already runs on, because the locked packages needed it.

Security

  • guzzlehttp/guzzle 7.15.5 and league/commonmark 2.10.3, with guzzlehttp/promises 2.5.3, guzzlehttp/psr7 2.13.1 and nette/schema 1.3.6. The locked versions carried published advisories (composer audit), among them CVE-2026-69245 and CVE-2026-69246 (Guzzle) and CVE-2026-71478 and CVE-2026-71488 (CommonMark).

Changed

  • PHP 8.4.1 or newer is required — in composer.json, the installer's requirement check, the setup script, update.sh, the deployment check and the documentation. They said 8.3, but the locked Symfony 8.1 packages already require PHP 8.4.1, so composer install failed on PHP 8.3 anyway.

Fixed

  • The test run no longer writes into storage/logs/laravel.log. phpunit.xml sets LOG_CHANNEL=null; without it every run landed in the working copy's log.
  • The test run keeps its array mailer. MailConfig::apply() turned MAIL_MAILER=array into sendmail, so a test without a mail fake called the real sendmail. array now stays as long as no transport is stored under Settings; operation is unaffected.
  • .env.example no longer offers the log mailer. It was never honoured: the transport under Settings → E-Mail knows smtp and sendmail only.

5.0.1 — 2026-09-25

The product site stands on its own: it has its own imprint, privacy notice and accessibility statement, and contact for licensing and security reports runs through a confidential issue. The application itself is unchanged; updating is update.sh and nothing else.

Added

  • An imprint, a privacy notice and an accessibility statement for the product site, as pages of the site itself (site/rechtliches/, served at /impressum/, /datenschutz/ and /barrierefreiheit/). They used to be links to pages on another site. The imprint keeps one e-mail address on that domain on purpose — an imprint needs one. A detail still missing is marked ⟦FEHLT: …⟧ in the source; the pages job refuses to publish while one is left.

Changed

  • Commercial licensing, the corporate CLA and security reports go through a confidential issue in the GitLab project. The previous contact e-mail address and contact page are gone everywhere — LICENSE.md, legal/LICENSING-FAQ.md, CONTRIBUTING.md, the READMEs and the product site. A confidential issue is visible only to the person who opens it and the project members; opening one needs a GitLab account. The Required Notice in LICENSE.md now names the repository instead of a personal website.
  • The product site no longer has its domain in the code. Every link inside it is relative to the root and works under any domain pointed at the site. The one address that canonical, Open Graph, sitemap.xml and robots.txt need comes from the CI/CD variable SITE_URL, otherwise from the Pages address GitLab sets (CI_PAGES_URL); the release notes take the same. README.md, README.de.md, INSTALL.md and composer.json link to the documentation in the repository instead.

Fixed

  • The product site works better with a keyboard. Every page starts with a skip link, the start page has a main landmark, and tables and code blocks that scroll sideways on narrow screens can be reached with Tab — Safari could not scroll them from the keyboard before. Tables also keep their table semantics in WebKit: the scrolling moved to a wrapper instead of display: block on the table.
  • Every page of the product site fits a 320 pixel wide screen. All documentation pages scrolled sideways there: the "Zur Übersicht" button in the header was 10 pixels too wide, single long words in headings ("Systemanforderungen", "Datenschutzerklärung") and long paths or class names in running text did not break, and neither did the labels of the previous/next links. Headings and those labels now hyphenate, inline code breaks where it has to, and the header is a little narrower on small screens. Checked on all 42 pages in light and dark mode.

5.0.0 — 2026-09-25

Four flaws in the account stack are closed: a password reset link could be redeemed as an invitation for seven days, the second factor could be guessed without the account ever locking, the installer's completion marker reopened the whole installer, and mails went out as "Laravel" instead of under the installation's name.

Two things change on an existing installation, and they are why this version is a major one. They come down to three steps, one before and two after the update:

  • Before updating, if you set a sender name of your own in MAIL_FROM_NAME: enter it under Einstellungen → E-Mail. From this version the .env value no longer counts; mails go out under the name entered there, and when that is empty, under the installation's name from Einstellungen → Allgemein.
  • After updating, send every open invitation again: under Nutzer, "Einladung erneut senden" (the envelope) next to each account that has not set its password yet. Invitations now have their own token table, and the old rows cannot be told apart from reset links, so they are not carried over and stop working.
  • After updating, look at the sender name under Einstellungen → E-Mail. If it reads "Laravel" or your old APP_NAME, an earlier save stored it there; empty the field.

The update brings one new migration; update.sh runs it.

Fixed

  • The test suite's database guard now fires before the database is touched. The base test class checked for the testing environment and the in-memory SQLite database only after parent::setUp(), but Laravel runs RefreshDatabase (migrate:fresh) inside that call. Against a wrong database — a cached configuration, a DB_DATABASE from the shell — the guard reported the damage instead of preventing it: a probe run with DB_DATABASE=<file> migrated every table into that file before it failed. The check now runs in setUpTraits(), where the configuration is readable but no database trait has run yet; the same probe run aborts and leaves the file untouched. This only concerns running the tests; installations are not affected.
  • Mails without a sender name of their own go out under the installation's name. The sender name was meant to fall back to the name under Einstellungen → Allgemein, and the form shows that name as its placeholder. In fact it fell back to MAIL_FROM_NAME from the .env, which derives from APP_NAME and reads "Laravel" when neither is set — so the installation's name only arrived if that variable was explicitly empty. The form also filled the field with that value, and saving it once stored it as the operator's own entry. Such a stored entry stays as it is: whoever finds "Laravel" or the old APP_NAME in the field under Einstellungen → E-Mail should empty it. MAIL_FROM_NAME in the .env no longer has any effect.

Security

  • A password reset link can no longer be redeemed as an invitation. Both token types shared the table password_reset_tokens, and each checked a row against its own lifetime. A reset link, valid for 60 minutes, could be redeemed via /invitation/… for seven days — a reset mail left lying in a mailbox or intercepted stayed a way into the account for a week. Invitations now have their own table user_invitation_tokens (new migration), and an open invitation and a requested reset no longer displace each other. Invitations sent before the update stop working and have to be sent again from the user management: the shared table does not record which row came from which, and carrying all of them over would have reopened the same gap for another seven days.
  • Five wrong two-factor codes lock the account for 15 minutes. Until now only the route limit applied (20 attempts per minute and IP address). With a known password that is roughly an 8 % chance per day and address to guess a code — and the second factor is exactly what should hold once the password is lost. The lock belongs to the account, not the address: after the fifth miss the half-finished sign-in is discarded, and a new attempt with the password from anywhere meets the same lock. The price is that someone who knows the password can keep the owner out for 15 minutes; that is a nuisance instead of a takeover, and a reason to change the password.
  • The installer's completion proof opens only the completion page. After the last step the session carries a marker so that /install/done stays reachable once the installation counts as finished. That marker reopened the whole installer for the rest of the session: POST /install/database could rewrite the .env and the database access, and POST /install/admin could turn any existing account into an administrator by its address and set a new password for it. It now applies to the completion page only.
  • Login, two-factor sign-in, password reset and invitations had no tests at all. Two new test files cover them, a third covers the mail settings, and the installer test gained the case above. All four flaws were found on 2026-09-24 while carrying the account stack over into Secretshare.

4.3.4 — 2026-09-24

Fixed

  • fonts is a reserved address now. public/fonts/ holds the self-hosted typeface, and the web server answers that path before the application is asked. A custom slug or an editorial page fonts at the root was accepted and never reached. A new test lists every directory in public/ (plus build and storage, which only exist on a target) and fails when one is missing from the list. Found while porting the route check back to Fileshare. A link or page that already holds fonts stays as it is — it was unreachable before and remains so; such a page needs a new slug the next time it is saved. Inside an account namespace (/xy/fonts) the word stays free.
  • areas is a reserved address now. The area overview has lived at /areas since 2.3.0, but the word was missing from the list of reserved slugs. A custom slug or an editorial page areas at the root was accepted and never reached — the fixed route answers first. A new test derives every first path segment from the registered routes and fails when one of them is missing from the list, so the next route cannot slip past it. A link or page that already holds areas stays as it is: it was unreachable before and remains so. Such a page needs a new slug the next time it is saved. Inside an account namespace (/xy/areas) the word stays free, as every reserved word does.
  • The installer's skip link moves the focus. Its <main> lacked tabindex="-1", so in several browsers the link scrolled the page but left the focus where it was, and the next Tab went back to the top. The main layout has had the fix since 1.0.0; the installer has a layout of its own and did not.
  • The package examples in docs/aktualisieren.md name the current version. They still said shortlink-v4.0.0. The file is now part of the version markers in step 2b of RELEASE.md, as it already is in Fileshare, so the next release updates it with the other two.
  • The READMEs name the collation the application actually uses. They explained the case-insensitive slug comparison with utf8mb4_0900_ai_ci, the MySQL server default; the application connects with utf8mb4_unicode_ci (config/database.php, and the installer). The statement itself holds for both — each ignores case.
  • The update no longer restarts a queue this application does not have. update.sh, the deploy job and the manual steps in docs/aktualisieren.md ran php artisan queue:restart. There is no queue worker and no background work here, so the command only wrote a cache key that nothing reads. It came over from Fileshare, where a worker exists — as did the next three.
  • The hint after the first deployment names the right cron entries. It asked for a queue worker and a clean-up job; what this application needs is shortlink:cleanup and shortlink:check-targets (see INSTALL.md).
  • The installer's requirements page gives the right reason for artisan. It said the file was needed for clean-up and "the queue"; it is needed for the two cron entries, clean-up and the target check.
  • The backup comment in update.sh gives the real reason for saving .env first. It cited a file-encryption key that does not exist in this application. What APP_KEY protects here is the two-factor secrets and the stored mail password.

Security

  • Every rate limit now counts only its own requests. All throttled routes shared one counter per visitor (per account when signed in), because Laravel builds that counter from the IP address or the user alone, not from the route. Two things followed. Limits ran out on requests they were never meant for: a visitor who had clicked "Weiter zu …" on ten preview pages was turned away at a link's password prompt before the first attempt, five such clicks were enough to block "Passwort vergessen", and ten live checks of a short address while typing it blocked sending a link by mail. And the window was set by whichever route counted first: after one login attempt (20 per minute) the lockout of the password prompt (10 attempts in 10 minutes) expired after a minute, so the password of a link could be tried up to nine times a minute instead of ten times in ten minutes. The installer's form steps counted every submission twice. Each throttle in routes/web.php now names its own key; the limits are unchanged, and a test fails for any throttled route without a key of its own. Lockouts running when the update is installed are lifted once, because their counters sit under the old key.

4.3.3 — 2026-09-20

Added

  • The screenshot theme is a file now: resources/css/themes/glab-page.css. The pictures in resources/images/glab-page/ are teal although the shipped application is black and white — that theme was uploaded through the interface and therefore lived in storage/app/private/theme/, which is gitignored and excluded from deployment. It was gone by the next reset, and the next screenshot session would have had to reconstruct it. The values here are recovered from site/src/app.css.

    It is not the default and does not ship. A freshly installed instance stays monochrome; which colour an installation wears is the operator's decision.

Changed

  • The form placeholders that named a domain now use example.org. Five of them: the SMTP server, the share dialog, the trusted-domains field and its help text, the search field and the target-URL field. Nobody sends mail to a placeholder and nobody resolves one, so nothing was at risk — but it is the same rule .env.example follows since 4.3.2, and leaving five exceptions behind means the pre-release check reports the same five lines forever until somebody stops reading it.

Fixed

  • Muted text on the product site failed WCAG 2.1 AA. --c-muted and --c-on-footer were #7C918C, which is 3.34:1 against the page and 3.10:1 against the offset surface — below the 4.5:1 that normal-size body text requires. It carries the six footer links, the figure captions on the landing page and the group headings in the docs sidebar, so it is body text, not decoration. Now 5.05:1 and 4.69:1. The dark values were already above the threshold and are untouched.

  • Three tests in UserModeTest could fail without anything being wrong. Random prefixes are drawn from the same pool of 1024 values as the ones derived from an account name, so a test that creates dice-rolled accounts and then expects a specific derived prefix can find it already taken — once in a thousand draws, and across the class about once in a hundred runs. The product behaved correctly every time; the precondition of the test did not hold. Measured before: 2 failures in 200 runs, then a third case at 3 in 400. After: 0 in 500 runs of the class and 0 in 30 runs of the full suite.

  • A test that verified nothing in the pipeline now runs there. png pixels match the qr matrix measures the generated PNG against the QR matrix module by module — 845 of the suite's 2548 assertions. It needs gd, the runner had none, and it skipped itself: the pipeline reported 1 skipped, 493 passed (1703 assertions) and a green tick. Its own comment says what that left behind: "Ohne diese Prüfung wäre alles andere in dieser Datei eine Aussage über Dateigrößen."

    Two changes, because either alone would let it happen again. The test image installs gd (needs libpng-dev, about 15 seconds). And the skip now throws when CI is set — locally a PHP without gd is a fair reason to skip, in the pipeline the extension is promised, and a promised service that quietly fails has to be loud.

4.3.2 — 2026-09-19

Changed

  • Tailwind no longer scans the documentation, the tests and site/. It reads every file of the project that is not in .gitignore, Markdown included — so CONVENTIONS.md, the changelog and the docs pages, which spell out the token names in prose, were producing utilities no template ever uses. The presentation page under site/ made it worse: it is real markup with real utilities, but with its own tokens and its own build, so its hero and grid classes landed in the application's stylesheet as well.

    100 utilities gone, 84.9 kB → 76.8 kB (gzip 15.1 → 13.7 kB). Nothing a template uses was affected — the one class that looked like it was, bg-card-hover, exists only inside a Twig comment; what the component actually applies is hover:bg-card-hover, and that is still there.

    Worth more than the bytes is the direction: a sentence in a guide should not be able to change the stylesheet that every visitor downloads.

  • .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 short address, QR code and mail link, 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.

4.3.1 — 2026-09-12

The public site only. The application is unchanged — an installation on 4.3.0 gains nothing from updating.

Added

  • The site has its own 404 page. GitLab Pages answers an unknown address with its own error page, which offers neither the documentation navigation nor a way back. /404.html is now built from the documentation layout and carries the full sidebar, so a stale link ends on a page that names three next steps instead of a dead end. It is marked noindex, follow and is deliberately absent from the sitemap: an error page in the index is a result line that helps nobody.
  • Shared links show a preview card. og:image was missing, so a link pasted into Slack, Teams, WhatsApp or LinkedIn arrived as a line of text. site/assets/og.png now carries the logo and the wordmark as the header shows them, with the claim beneath, in the 1200 × 630 that every platform expects; twitter:card moves from summary to summary_large_image accordingly. One card for all pages, not one per page: it says whose link this is, while what the page is about already stands in the title and the description. The drawing is site/assets/og.svg and carries its font as Base64, so it renders correctly on a machine without Quicksand installed — site/assets/README.md says how to produce the PNG from it again.
  • The sitemap states lastmod. The date comes from the commit history of the page's own source file, not from the file system — in the build every file carries the time of the checkout, an entry that would jump on every run and tell a crawler nothing. Without a history (a shallow clone, a package without .git) the entry is left out and the sitemap stays valid; the pages job therefore fetches the full history and installs git, which node:22-alpine does not ship.

Changed

  • The dark mode now reaches the browser's own surfaces. color-scheme was set in the application's stylesheet but not in the site's, so scrollbars and native controls stayed light on a dark page. Two theme-color entries follow the system for the browser bar on mobile, and the toggle in the header pulls them along — it reads the value from the --c-page token rather than a second list of colours in the markup.
  • The two font weights that carry the top of each page are preloaded (heading and navigation on the landing page, body text and navigation in the documentation). Only those two: every further preload is a request that delays rendering instead of speeding it up.

4.3.0 — 2026-09-07

Added

  • The overview got a search mask. Above the list there is now a search field and a switch Nur meine Links; the table narrows while typing, without a submit button and without reloading the page. Areas ordered the list but could not answer the question anyone actually has in front of a long list — "where is the link I sent last week": the address is one someone else may have filed under an area you would not have chosen. Searched are five fields per link, not just the target and the short address: short link (including its namespace), title, full target URL, area name and the account that created it. Several words narrow further and may match in different fields, so sommer kunde finds the link titled Sommeraktion whose target points at kunde.de.
  • Comparison happens in PHP, not in SQL: LIKE is case-insensitive in MySQL but ASCII-only in SQLite, so Übersicht would not have been found by übersicht there — and uber finds über in neither. The search folds both sides with the same table the area sorting already uses (Areas::sortKey()), which also makes strasse find Straße. Two of the five searched fields do not exist in SQL as what they are anyway: the short link is assembled from prefix and slug, and an area name counts only when the account is allowed to see it — a link assigned to another account in user mode keeps its previous area, and the search must not find it through a name it would never display.
  • The search runs after the access rule, never before it: LinkAccess decides what an account may see, LinkSearch what it wants to see of that. The other order would have turned the field into a way to probe another account's inventory, word by word.
  • Zero results now say which of the two things is true. "In dieser Installation gibt es noch keine Shortlinks" is a statement about the inventory and would have been wrong while a search was active; with a term the empty state names the way out (fewer words — all of them have to match), with only the switch set it names the switch. The mask itself stays visible at zero results, otherwise a search would take away the field needed to undo it. Areas without a hit drop out of the list while a search is active and come back afterwards.
  • Without JavaScript the mask stays a plain GET form: the submit button lives in a <noscript> block, so it is gone when the script runs instead of flashing on every page load. With JavaScript the fragment already used after deleting a row (dashboard.region) is fetched with the filter appended, 250 ms after the last keystroke — one request per word, not per letter. The filter therefore exists once, on the server; rebuilding the area sections, their counters and the empty state in JavaScript would have been the same presentation written twice. The browser address carries the search, so a filtered view can be bookmarked and passed on, and the row counter (3 Treffer) is announced through a role="status" region.
  • The "Neuen Shortlink anlegen" form can be folded away. It is delivered open, so nothing looks different until someone clicks its heading — but during maintenance of a long list it took about 400 pixels above the table for an action nobody is performing right then. Collapsed, a single labelled bar remains, so creating a link is one click away rather than gone. molecules/card.twig gained a collapsible prop for it: the header becomes the <summary>, the card becomes the <details>, with no JavaScript involved — keyboard operation, the state for assistive technology, Ctrl+F and printing all come with the element, and the fields of a collapsed form are still submitted. The state is deliberately not remembered (same decision as the area accordion): a remembered one turns a stray click into a form that stays missing on the next visit, and it guarantees validation errors are always visible.
  • The card's separators moved from divide-y on the frame to border-t on the sections. Rendering is identical for every existing card, but divide-y targets every child except the last, and a collapsed <details> keeps its body in the DOM — the <summary> would have carried a line sitting right on the card's own border.

4.2.1 — 2026-09-07

Changed

  • The chapter "Aktualisieren" never mentioned the browser. Preparing an update in the interface — Einstellungen → Systemdaten, button Paket bereitstellen — was documented only in the settings reference, which is not the page anyone opens when they want to update. It is now the first section of the update chapter, including the command the page hands over, and both pages link to each other. Both READMEs gained the same two sentences: they had described the system page as operating values and diagnostics only, leaving out the version card entirely.

4.2.0 — 2026-09-06

Fixed

  • The browser installer's final page told operators to set up a queue worker. It showed queue:work --stop-when-empty and explained it with "every change to a file's encryption state stays pending forever" — both left over from the file service this project grew out of. This application has no background work, so anyone following it added a cron entry that did nothing. Worse, the page listed only two entries and the useless one took a slot: the target check was missing entirely, so an installation set up through the browser never checked a single target. The command-line installer had been corrected earlier; the page had not.
  • A fresh installation never built the production caches. Installer::finish() cleared config, routes and views but never rebuilt them, so an installation ran uncached until the first update — only update.sh and the pipeline knew the three commands. It now builds them in the same order, and reports a warning instead of failing if one of them cannot run. Deliberately skipped in local and testing: a config cache freezes the .env, which would make development confusing and would let a test run pick up the development database.
  • The notices on the installer's final page were headed h3 while standing directly under the page's h1. They are h2 now — someone navigating by headings was jumping from the page title into a level with no parent.

Changed

  • The documentation is restructured into short, single-topic pages grouped by audience — Erste Schritte, Betrieb, Benutzen, Projekt. The published site now has a grouped sidebar and next/previous links; the user handbook is on it for the first time, having previously been reachable only as raw Markdown in the repository.
  • Instructions no longer argue their case. Installation, requirements, cron and updates state what to do; the reasoning that used to sit between the steps is gone or has moved to where it belongs. INSTALL.md went from 457 lines to about 120 and is now the short offline copy that ships in the release package — the full path lives under docs/.
  • Every procedure is described in one place. The install steps used to stand in INSTALL.md, in the admin handbook, in site/content/docs.md and in both READMEs, and they had drifted apart: README.de.md listed Node 20 as a requirement while INSTALL.md said it was not needed, and the cleanup command had four categories in one file and five in another. The READMEs now show the short command and link onwards.
  • docs/ADMIN-HANDBUCH.md (1839 lines) and docs/BENUTZERHANDBUCH.md (799 lines) are split into one file per chapter under docs/betrieb/ and docs/benutzen/.
  • RELEASE.md: the two version markers to bump before a tag moved with their text — they are now in docs/uebersicht.md and docs/installation/paket.md.
  • Page titles now come from the page's own first heading instead of its sidebar label. Two pages were both labelled Grenzen and therefore shared one <title>; they now read Grenzen und bekannte Lücken and Grenzen des Systems. The documentation index no longer reads Shortlink — Shortlink. Every page description is the page's own first paragraph, so no two pages share one any more.
  • On the landing page, the closing section and the new FAQ section above it swapped backgrounds so the sections keep alternating; the two cards inside it moved from bg-page to bg-page-alt to stay visible on the lighter ground.
  • Kurz-Adresse is now Kurz-Link everywhere — 128 places across the interface, the documentation, the code comments and the tests. German readers hear Adresse as a postal address, not as a URL; Link is the word they already use. The field label is now Eigener Kurz-Link (optional), and one test assertion moved with it. The rename is not mechanical: die Kurz-Adresse is feminine and der Kurz-Link is masculine, so every article, adjective ending and following pronoun changed with it — Seine Kurz-Adresse bleibt … würde sie frei became Sein Kurz-Link bleibt … würde er frei. Left alone deliberately: the historical entry in this file that quotes the old label.
  • The installer's final page names the step that has to come first: confirming the operator's own address. Until that is done the account can do everything except create links, and the confirmation needs working mail — so the list is now explicitly in dieser Reihenfolge, and the legal placeholder pages moved to the end. The page also points at Einstellungen → Systemdaten, where both cron entries stand again, there with a copy button and the time of the last run.

Added

  • DOCS-CONVENTIONS.md — the rule the restructuring follows, so the reasoning does not creep back into the instructions: three levels, and the test of whether a paragraph can be skipped without getting the next command wrong.
  • docs/fragen.md — the questions that come before the decision, in 28 entries across five blocks: what Shortlink is and is not, licence and cost, what the operation demands, personal data and accessibility, and the limits of everyday use. The existing docs/benutzen/haeufige-fragen.md keeps the questions that come after it, about operating the interface. Published at /docs/fragen/; the seven that decide most often also stand on the landing page as a details/summary accordion — native keyboard operation, and the answers are in the markup whether or not JavaScript runs.
  • The site now carries what a public page needs to be found: canonical, Open Graph, robots.txt, sitemap.xml and JSON-LD — WebSite and SoftwareApplication on the landing page, BreadcrumbList and TechArticle on every documentation page, FAQPage on the FAQ. All of it is derived at build time, never written a second time: the description from the page's first paragraph, the questions and answers from the headings of docs/fragen.md, the version from the topmost version heading of this file, the address from one constant in site/build.mjs. A hand-kept copy of a fact inside structured data drifts, and nobody sees it drift.
  • Stated openly, because it changes what to expect: FAQPage no longer produces rich results in Google Search. Google stopped showing them in May 2026 and removed the report and the Rich Results Test support with it. The type remains valid schema.org and is read by other search engines and by answer services — that is why it is here, not for a Google badge.

4.1.1 — 2026-09-04

Documentation and metadata only. The application is unchanged — an installation on 4.1.0 gains nothing from updating.

Changed

  • Both READMEs, composer.json and the release note now name the project's website. The documentation is published there as pages rendered from this repository's own Markdown files, so INSTALL.md and the handbooks stay the single source. The release note is the reason this is a release at all: its text lives in the pipeline and only takes effect with a new tag.

4.1.0 — 2026-09-04

The create form on the overview was rebuilt: the target address is the one field the form is aiming at, and the options no longer push it off the screen.

Changed

  • The create form on the overview reads as a hierarchy instead of four fields of equal weight: the target address — the only field that has to be filled in — sits on its own surface (bg-panel) and is the one field in size lg, the optional custom address takes half the width with the options panel beside it, and Shortlink anlegen spans the full width. Editing a link is unchanged: there the target is one field among several, and in the drawer a second surface inside a narrow column would only be a frame around a frame.
  • The options panel now opens on a validation error only when the error is in one of ITS fields. An error on the target or the custom address left it closed anyway in terms of content, but it pushed the offending field out of view now that the panel sits beside the address field.
  • Opened, that panel now lies OVER the page instead of pushing the target address and the button some 600px down. It closes on Esc, on a click beside it and as soon as focus leaves it — without that last one you would tab out of the overlay onto a field lying underneath it. Without JavaScript it still opens and closes through its <summary>, and its fields are submitted either way. When the server delivers the panel open because it holds error messages, it stays in the flow: an overlay that is already open on load covers fields nobody asked to have covered.
  • The optional custom address is exactly as high as the options box beside it (new input size tall, the padding of a box), and the target address has the same 1px border as the cards — the surface alone was a quiet boundary, the border makes it an edge.
  • The dialog after creating a link now offers both ways of sending it — the mail program (mailto) and the system — next to copying and the QR code. They were on the detail page only, which is a page change nobody has in mind at that moment. As buttons rather than the detail page's cards: two 3:2 cards are some 150px tall together, and this dialog is meant to stay usable on a phone without scrolling. The overview therefore also carries the send dialog that the second button opens; its table rows do not need it.

Added

  • --e-panel: an emphasised surface for a section INSIDE a card that contains form fields. It is a shade of the card, not its opposite — unlike --e-accent, which flips with the mode and would leave a form field white on white in dark mode. Its opacity is calculated rather than picked: a field's error message on it still reaches 4.7:1 (see ACCESSIBILITY.md).
  • size (md/tall/lg) on atoms/input.twig and molecules/form-field.twig — tall for a field that has to line up with a control beside it, lg for the one field a form is aiming at.
  • command/commandfor on atoms/button.twig — a dialog trigger does not have to be a whole cta-card.
  • clip on molecules/card.twig (default true, unchanged behaviour). On false the card no longer cuts off what reaches past its edge — needed by any card whose content floats over the page, and wrong everywhere else, because the rounded corners are what the clipping is for.

Fixed

  • The live check of a custom address now names the CONFIGURED address, not the host of the current request. It was the last place still building a URL from the request: open the interface through a second domain and „Frei — der Link lautet dann …" promised an address the link did not carry a second later.
  • The link in a notice no longer runs out of its own box. It sat beside the text and could not wrap, which held only as long as the text was short — the notice about a foreign domain is four lines, and „Systemdaten" ended up cut off at the edge. It now always sits under the text, the way notices with a title or a list already did.
  • Cards without a footer no longer end in a divider line and an empty 40px strip. The section was rendered whenever the footer block was defined — and card.twig defines it itself so that an embed can override it, so the condition was always true. It now renders the block first and asks whether anything came out of it. Cards that do have a footer (the link detail page and the drawer) are unchanged.

4.0.0 — 2026-09-02

A prefix that is swapped out now goes back into the pool, provided no address was ever handed out under it — and the cleanup run does the same for prefixes blocked by an older version.

Before updating: if your installation has prefixes that were swapped because they read badly, put them in the blocklist first. Until now a swap retired a prefix for good, so the blocklist was not needed for them. From this version the release cannot tell the two motives apart — it only sees that no address hangs on a prefix — and the first cleanup run after the update will hand such a prefix back out. The blocklist is App\Support\Prefixes::UNFORTUNATE; an entry there holds for every future assignment. Installations that never swapped a prefix, or swapped only harmless ones, have nothing to do.

Changed

  • A reissued prefix is now released instead of blocked, provided no address was ever handed out under it. Until now every swap took the old prefix out of circulation for good. That was right for the reason the swap feature exists — an unfortunate prefix should not reach the next account — but wrong for the reason it is now mostly used: an account trading its rolled k7 for the nk that matches its name has usually not created a link yet, and retiring one of the 1024 short namespaces forever protects nothing.

    One question decides it: is there a link under the old prefix? If not, the row disappears and the prefix can be handed out again. If there is, it stays blocked and is never reassigned — otherwise a second account could inherit xy and hand out /xy/sommeraktion a second time. A deleted link counts: its address is in circulation whether it still forwards or not.

    The reissue also had its order corrected, which this change made worth doing: the wish is now checked before the old prefix is touched, and the old row is released after the new prefix is secured. Without the first, a rejected wish left the account with no namespace at all and the old prefix already gone. Without the second, reissuing without a wish rolled the prefix that had just been freed — reliably so for the name-derived one, since that is what the assignment tries first.

  • The cleanup run now also frees swapped prefixes (shortlink:cleanup, category Freigegebene Namensräume ohne Konto und ohne Adressen, formerly … gelöschter Konten). It takes both origins — deleted accounts and swapped prefixes — under the same one condition as before: not a single link underneath, deleted ones included. For prefixes swapped by an older version this run is the only way back into the pool, and the first run after updating will free them.

    What this gives up, and the step it asks of you. A prefix swapped because it read badly — a party abbreviation, an insult the blocklist does not know — goes back into the pool and can reach the next account. The release cannot tell the two motives apart; it only sees that no address hangs on it. To keep one out of circulation for good, put it in the blocklist (App\Support\Prefixes::UNFORTUNATE) — that is the place that says "never again", and unlike a blocked row it holds for every installation. If your installation has prefixes that were swapped for that reason, add them before the first cleanup run after this update.

3.1.0 — 2026-09-02

An account's namespace prefix now comes from its name instead of the dice: jd for „John Doe", mg for „Muster GmbH". It sits in every address the account hands out, so it should read like the account. Nobody's existing prefix changes — the reissue dialog offers the matching one as its first suggestion, so an operator can catch up account by account, on request.

Changed

  • An account's namespace prefix is now derived from its name instead of being rolled. The prefix sits in every address the account hands out, so it should read like the account: jd for „John Doe", mg for „Muster GmbH". Two words give the initials of both, a single word gives its first and last letter („Kanzlei" → ki), and with more than two words the first and the last one count — what stands in between is usually a name particle or a legal form, while the surname is what someone searches the account by.

    Characters outside the address alphabet are translated, not rejected: „Müller" → m, „Gauß" → gs, „Šimon Čech" → sc, „Иван Петров" → ip. That goes through Str::ascii() — a table, not iconv('ASCII//TRANSLIT'), which returns something different depending on the host's locale.

    l and o are the case with no good answer: they are missing from the alphabet because they are misread as 1 and 0 — which are missing too, so there is nothing that looks like them. Rather than roll the dice for every name starting with an L (not a rare case in German), the next usable letter of the same word moves up: „Lisa Meyer" becomes im. A prefix with one letter still right is closer to the name than two random ones.

    The dice remain, as the way out. They take over when the name yields no two usable characters („Loop" is a single one after filtering), when the matching prefix is on the blocklist (Sarah Schmidt would give ss), or when it already belongs to another account. The name is tried once: an account has exactly one name, and if its prefix is taken there is no second one that would still match it. Length, alphabet, blocklist, uniqueness and the reissue path are unchanged.

  • Reissuing a prefix now offers the matching one first. Under Verwaltung → Konten → Bearbeiten the first suggestion is the prefix derived from the account name, marked passt zum Namen, whenever it is free; the rest are rolled as before. This is what makes the new rule reachable for existing installations: their prefixes were rolled before the rule existed, and updating deliberately does not swap them — addresses already handed out hang on them. Through this suggestion an operator can catch up account by account, on request, with the old prefix blocked for good exactly as before. The same applies to a renamed account: it keeps its prefix, because the rule runs when the account is created.

3.0.0 — 2026-09-02

Everything this installation hands out — short addresses, QR codes, the CSV export, the links inside system mails — now carries the domain from APP_URL instead of the domain the interface happened to be opened on. Administrators additionally get the installation's open points below the header, on every page.

Before updating, check APP_URL. It decides, from this version on, which domain every address you hand out carries. Until now that came from the request, so an installation whose APP_URL was never corrected — the factory http://localhost, or a domain that was changed later without touching .env — worked without anyone noticing. From this version it does not: every short link and every QR code in the interface would read http://localhost/…. Nothing crashes, everything is wrong. This is why the version is a major one, and it is the one manual step this update has:

grep '^APP_URL=' .env     # must be the full public address, with https:// in front

Already issued links are unaffected — the host was never stored with them, and every domain pointing at the installation still resolves all of them.

Changed

  • Everything this installation hands out now carries the domain from APP_URL, no matter which domain the interface was opened on — short addresses, QR codes, the CSV export, and the interface links inside system mails (password reset, invitation, address confirmation, two-factor re-setup, „Link ansehen"). Before, an address was built from the host of the request that created it: whoever opened the interface once through a second domain pointing at the same server — an old domain, a test domain, www. in front, the bare IP — produced links carrying that domain from then on. Nothing about that looks wrong on screen. It looks wrong on the poster, and by then the address is out: printed, mailed, encoded into a QR code, and in circulation for years.

    This is deliberately not a block. Every domain pointing at the installation still resolves every short link, still shows every preview page and still accepts a login — only what the application hands out is fixed. A block would take the whole installation down the moment APP_URL is wrong, which is exactly when the operator needs to reach it.

    The line runs between what is handed out and what is browsed. A link INSIDE an open page keeps the host of the request: it carries the user onward from where they are, and their session hangs on exactly that host. A link in a mail has no such tie — it is opened hours later in a different browser, forwarded, printed, pasted into a ticket. From the moment it is sent nobody corrects it any more, which is the situation a short link is in, so it gets the same rule.

    Two internal redirects were changed along with it (after unlocking a password-protected link, and when the session expired behind the preview page): they now keep the visitor on the domain they arrived on. Sending them to the configured domain would have left the session — and with it the unlock — behind on the old one, putting the password prompt in front of them again and the visitor in a loop. The password form's own target (Link::unlockUrl()) stays on the request host for the same reason.

    A APP_URL without a scheme (APP_URL=go.kunde.de) counts as not configured, and nothing is guessed: https:// would be wrong on an installation without TLS and http:// on every other, and the guess would end up inside a printed QR code. The request host applies again in that case — the previous behaviour — and Settings → System data says so.

Added

  • A permanent notice below the header when the interface is opened through a different domain than the configured one. It names both addresses and states that the application works normally there. Without it the difference is invisible: someone creates a link on alt.kunde.de, reads go.kunde.de underneath it, and takes that for a fault — or, worse, takes the wrong domain for the right one.

    Only for signed-in users, and it cannot be dismissed. A visitor following a short link has nothing to do with the question, and which address the operator actually runs is a detail of their setup that is none of a visitor's business. It stays because the condition stays: a notice closed once is invisible from the second link onwards, and the second link is the one nobody looks at any more.

  • Settings → Links states which address short links carry, above the target-domain list. Read-only — the value lives in APP_URL and has exactly one home; a second one beside it would drift, and then the interface would show a different address than the mail the same application sends a minute later. It is on that page, not only under System data, because that is where an operator goes to look for the address of their links.

  • Administrators are shown the installation's open points below the header, on every page. The same list Settings → System data carries — missing cron entries, an active debug mode, APP_ENV=local, a missing Public Suffix List, a click log without a retention period, no configured public address.

    Everything on that list shares one property: it does not stand out in daily use. A missing cron entry looks like a system that had nothing to clear away. An active debug mode looks like nothing at all. A missing Public Suffix List looks like an application that is simply fond of preview pages. Whoever opens System data already has the suspicion — and the suspicion is the part that was missing.

    It cannot be dismissed. A close button would turn each of these into something one can also click away, and then one clicks it away. It ends one way: the point gets fixed, and it disappears by itself. Signed-in users without operating rights never see it — a missing cron entry is nothing they can fix, and a permanent notice without a course of action only wears down the attention the administrator needs.

    One check is held for up to a minute rather than run per page: whether the Public Suffix List is readable costs around 40 ms because the list is parsed (325 KB), and that is not worth paying on every click. php artisan shortlink:suffix-list clears that verdict itself. Everything else — .env, settings, cron — is live, so a correction takes effect on the next page load rather than looking like it did not work. Settings → System data always checks fresh.

  • Settings → System data warns when no public address is configured. It is the one warning in that list whose consequence cannot be taken back: a link handed out with the wrong host is gone. The entry there is now labelled „Öffentliche Adresse (APP_URL)" — the same wording the installer uses — instead of „URL".

Fixed

  • The „cron entry missing" warning pointed at a place that is not always there. It said the ready-made line stands „weiter unten auf dieser Seite" — true on Settings → System data, where it used to appear only, and wrong in the new notice below the header, where there is no further down. It now names the page instead of a direction.

2.6.0 — 2026-09-01

Links can be taken out of the installation as CSV — the whole visible pool or a single area — and the database can be downloaded from Settings → Maintenance.

Added

  • Every account can now download the links it can see as a CSV file, and each area has its own download. The overview carries a „Als CSV herunterladen" button in its header; Bereiche has a download icon per row. Two columns — the complete short URL (scheme, host and namespace included) and the target. A short URL without its host is nothing outside this application: whoever gets the file would have to type the domain in front of it two hundred times, which is exactly the work the file exists to avoid.

    Restrictions on a link are irrelevant: expired, disabled, password-protected and exhausted links are all in there. The question the file answers is „which addresses exist", not „which of them forward right now" — and an export that hid disabled links would be wrong for the very use it gets pulled for: checking what still holds on a printed leaflet. Deleted links are left out; their row stays in the database so the slug remains taken, but the address no longer forwards and belongs in no address list.

    Both paths go through the same query as the overview (App\Support\LinkAccess), so nothing comes out that the screen does not already show — in user mode only the account's own links. The area export is scoped the same way on top of the area itself, which matters for one case that is easy to miss: a link reassigned by the operator still carries the area of its previous account. Areas::group() already catches that for the overview; the export had to draw the same line, or the area's owner would have found a stranger's link in their file. There is deliberately no switch to turn the export off — whoever may see the list may take it with them; the lever for a stricter installation is the mode (Einstellungen → Funktionen), not the export.

    The format is CSV per RFC 4180 — comma, CRLF, UTF-8 with BOM — written by App\Support\Csv rather than fputcsv(), whose defaults have been changed twice (PHP 8.1 and 8.4): for a file meant to open on someone else's machine, the output must not depend on the host's PHP version. Numbers, LibreOffice and OpenOffice open it on a double-click. Excel is the documented exception: it picks the separator from the Windows list separator, not from the file, so on a German Windows the way in is „Daten → Aus Text/CSV". That trade is deliberate — the file is correct, and a semicolon would be more convenient in one place and wrong everywhere else.

  • Settings → Maintenance can now hand out the database as a file. SQLite comes as the database file itself, produced with VACUUM INTO so it is a closed state — a plain copy() during a write yields a file that opens fine and is missing rows, which is a backup that looks like one and is not. MySQL/MariaDB comes as SQL text (DROP TABLE + CREATE TABLE + INSERT), read inside a REPEATABLE READ snapshot so table 12 cannot see a newer state than table 3.

    Built in PHP, without mysqldump and without a shell call — the same reason App\Support\SystemInfo avoids one: on hardened and shared hosts exec/shell_exec are disabled and mysqldump often is not installed at all. A button that returns an empty file on half the installations is worse than no button, because it is discovered when the backup is needed. Rows are read in blocks of 500 and streamed out, so memory does not grow with click_logs. With DB_PREFIX set, only this installation's tables are dumped: the installer deliberately does not require an empty database, so foreign applications may sit in the same schema and are none of this administrator's business.

    The card states plainly what the file is: the full personal data set of the installation — password hashes, sessions, click logs with truncated IP addresses, send logs with recipient addresses. And what it is not: it does not contain the .env, so it does not contain the APP_KEY. two_factor_secret and two_factor_recovery_codes are encrypted with it; restoring the database without the matching .env produces an installation nobody with a second factor can get into. That warning sits next to the button, not only in the manual.

    It is the handle before an intervention — before cleaning up, before an update, before switching the link mode — and says so: no replacement for the host's regular backup. Which is also why it sits above „Aufräumen" on that page rather than below the button that deletes logs for good.

2.5.0 — 2026-09-01

Settings → System names the two cron entries a clean operation needs, and says whether they are running.

Added

  • Settings → System now names the two cron entries and shows whether they are running. Which entries a clean operation needs was documented in INSTALL.md only — out of reach for whoever takes over a finished installation, which is most people who ever look at that page. Whether they were set up was visible nowhere at all, and their absence never announces itself: nothing crashes, retention periods simply stop applying (the click log keeps growing, with personal data in it) and no target is ever re-checked (a shortlink rebuilt to point somewhere else keeps forwarding).

    The new card sits directly below „Fassung" and carries, per entry, what it does, what breaks without it, the last run, and the ready-made cron line including the path of THIS installation. A missing or overdue entry also raises a warning at the top of the page.

    What it does not claim is to have read the crontab — this application does not call a shell (see App\Support\SystemInfo). It records when the commands last ran (App\Support\CronJobs), which is the question that matters and the one that can be answered honestly. Two things deliberately do not count as a run: the dry run without --force (it is what the manual recommends before switching the schedule on — counting it would let the preparation hide the very state the display exists to reveal) and the cleanup button under „Wartung" (it does the same work but proves no cron entry).

Changed

  • The font files the pages actually ask for now match the ones they declare. The @font-face for Quicksand 300 was commented out — the weight is declared nowhere in the interface, so the browser carried a rule it could never use. The bold cut (700) is the opposite case: it is used, but it was the only one missing from the preload list in the layout, which is why bold text arrived a moment late and swapped in after the fallback. Nothing to do for an operator; both files were in the package before and after.

2.4.0 — 2026-09-01

The link overview shows the state of a link as a symbol, so the table fits the space the areas left it.

Changed

  • The link overview table was cut down to fit the space the areas left it. Since the overview groups links into an accordion, the same table has less room — and four of its six columns spent that room on a state written out in words („kein Ablauf", „ohne Passwort"), identical in every row and with a heading wider than the data below it.

    The rule now: a column showing a state out of a few possibilities carries an icon (in the heading and in the cells); a column showing a value keeps text. So expiry, password and creator are icons, while the short address, the target and the click count stay readable text.

    • „Kurz-Adresse" is now „URL", and the column has a fixed width from md up — wide enough that any address the system hands out fits in full, including its prefix. A longer name someone chose themselves is cut with „…" (in full in the tooltip and the drawer). Below that the column stays flexible: a fixed one would push the row actions out of the picture, and an address is still a working link when shortened while an action outside the screen is nothing at all.
    • „Ziel" now shows host and path in one line and takes the width the other columns no longer need. Only the path is shortened — the host stays whole, because a target cut mid-host („kunde.de/kampa…" becoming „kund…") reads as an address that does not exist. It appears from lg up, the width at which what is left over is enough to read.
    • „Ablauf", „Passwort" and „Angelegt von" (now „Autor") are icons. Expired, no expiry, still valid; protected or not; mine, someone else's, or nobody's (deleted account). The plain text — including the expiry date and the creator's name with its prefix — stays in the markup as title and screen-reader text, and in full in the drawer.
    • „Aufrufe" keeps its number, now centred under its symbol; a reached click limit is the number in the warning colour instead of a badge below it.
    • A disabled link is marked by a symbol beside its address instead of a „stillgelegt" badge below it. The badge was 86px wide and did not shrink: one disabled link in the list forced that width on every row of the column and pushed the last row actions (edit, delete) out of the picture on narrow screens. As a side effect the title of a disabled link is now shown as well — the badge used to take its line, on exactly the link where one wants to know which one it is. The written-out badge stays where there is room for it: in the drawer and on the detail page.

    The four icon columns now stay visible down to the tablet and only drop off on a phone — together they cost about 210px, which used to be the width of a single spelled-out column.

    Accessibility: the statement never rests on the symbol alone (BFSG). Every icon carries its wording as sr-only text and as a tooltip, colour is only ever an addition to the shape, and both are covered by tests.

  • organisms/data-table.twig takes an icon per column — the heading then shows the symbol instead of the word, with label kept as tooltip and screen-reader text — and align: 'center' next to the existing 'right', for columns holding a number.

Added

  • molecules/state-icon.twig — one state as an icon with its wording as sr-only and title, for columns with more than two states (boolean-state.twig remains the one for yes/no).

2.3.0 — 2026-08-31

Links can now be sorted into areas, and the overview groups them accordingly.

Added

  • Areas (Bereiche) — a grouping for the link overview. An area is a label a link can carry: „Kampagne Herbst", „Messe", „Vertrieb". The overview turns them into an accordion, one section per area, each holding the same table as before. Links without an area stay on top under „Ohne Bereich".

    An area is an ordering aid, not a permission. It does not change who may see or edit a link — that remains App\Support\LinkAccess alone. This is written into the interface as well (subtitle of the areas page), because „area" invites the opposite reading: someone would file a confidential link into one, believing it hidden from the others.

    Nothing changes for an installation that ignores them. With no area defined, the overview is exactly the flat table it was — no accordion, no single collapsible wrapper around the whole list. The grouping appears with the first area.

  • A page to manage areas (/areas, „Bereiche" in the navigation). Create, rename, delete; the number of links per area is listed, and so is the number of links that are in none. Deleting an area keeps its links — they move to „Ohne Bereich". An area is a label, not a container.

    Not an administrator area, and that follows from the modes: in per-account mode the areas belong to individual accounts, and an account that cannot tidy up its own ordering would have to ask the operator about every typo. In the shared pool they belong to the installation — the same line the links themselves follow.

  • The area is optional when creating a link and changeable at any time — pick an existing area or define a new one right in the form. Two fields rather than one (a select and a name box) so the form stays usable without JavaScript; with JavaScript the name box only appears once „Neuen Bereich anlegen …" is selected. Filling in both is refused with a message rather than silently reinterpreted.

  • molecules/accordion-section.twig — the accordion section as a component (<details>/<summary>, heading inside the summary). Built without JavaScript on purpose: a JS accordion would leave a reader without scripting looking at an overview in which their links no longer appear. See ACCESSIBILITY.md.

  • The operator's oversight list (Verwaltung → Links) gained an „Bereich" column. It stays a flat table: it is filtered and searched for conspicuous links, not browsed for order — and in per-account mode it would group areas of different accounts that are allowed to share a name.

Changed

  • Switching the link mode now also decides what happens to the areas, and the two directions are deliberately not symmetric.

    • Shared pool → per account: every area is deleted (the links are kept and keep redirecting; they end up under „Ohne Bereich"). This is not tidying up but the consequence of a change in meaning: a shared area „Kampagne Herbst" is a shared matter. Left standing, account A would hold two links in it and account B three, with neither seeing the other's — each would see a fragment, take it for the whole, and act on it.
    • Per account → shared pool: every area is kept and becomes global. Where it came from is written into its name („Kampagne" → „Kampagne (Muster GmbH, xy)"), because two accounts may both have named theirs „Kampagne" and they stay two separate areas. Written into the name, not shown alongside it: after the switch the area belongs to the installation, so a „used to belong to" column would mean nothing, be wrong after the first rename, and never go away. In the name it is what it is — a piece of text that can be kept, shortened or removed under „Bereiche".

    Both consequences are stated on the settings card, with the count, before the click — the deletion cannot be undone.

  • organisms/data-table.twig takes a spacing prop (default mt-8) so the table can sit inside a container that brings its own padding.

2.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.

  • 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. The comment check has an occasion: a stray */ in a comment made half the element tokens vanish from the built stylesheet without a single warning from npm run build.

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 or border-table-line; bg-brand became bg-button, bg-accent or one of the hover tokens. Nothing changes visually — every new token resolves to the same value as its old source in both modes.

    The old names still exist in tokens.css so that a theme uploaded under 2.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

  • 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% 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.

2.1.0 — 2026-08-31

A short address can now be printed as a QR code, and a link that has just been created no longer has to be found in the list.

Added

  • QR codes for a short address, as PNG or SVG. For everything that gets printed: a notice, a flyer, a poster, a card on the counter. The code carries the short address, not the target — which is the whole point: expiry, click limit, password protection and a later correction of the target all keep working for a code that is already on the wall. A code pointing straight at the target would give up every one of them.

    Two versions, and the choice is not taste: PNG drops into a presentation, a document or a mail; SVG scales without loss and is what goes to a print shop — a PNG blown up to poster size goes soft, and that shows up after the print run. The link detail view and the side drawer offer both.

    Deliberately not offered: a logo in the middle, colours, rounded modules. Each of them costs readability, and a code a camera cannot read in half-light is worthless as a notice.

    No new server requirement. bacon/bacon-qr-code renders SVG, EPS and Imagick only; Imagick is rare on shared hosting and GD is not a requirement of this application either. The PNG is therefore written in App\Support\QrCode — a black-and-white image at bit depth 1 needs no imaging library, only zlib, which is part of every ordinary PHP build. Adding a mandatory extension for a feature meant to remove steps would have broken composer install on every server without it at the next update.

  • A dialog after a link is created, replacing the toast. It carries the short address with a copy button, where it points, how long it is valid, and the QR code with a download button.

    The overview lists all links of the installation and the system rolls the short address: without this dialog, someone who had just created a link had to find it in a long table under an address they had never seen. The toast named the address but disappeared again, and it could not be copied.

    Works without JavaScript. The dialog is delivered already open (open on the <dialog>) and closes through <form method="dialog">; with JavaScript it is upgraded to a real modal dialog with focus trap and backdrop. That is why creating a link no longer sets a flash message — the confirmation does not depend on scripting.

  • Settings → Features: a switch for QR codes, default on. Off removes them from every view and makes the download address answer 404 — not 403: a 403 would read as you lack the right and send the user to the operator to ask for something this installation does not have. Already downloaded or printed codes keep working; they are nothing but the short address in another shape.

  • User settings: which QR version you get. The dialog after creating a link offers exactly one download, because that is a moment for finishing, not for answering a question. Which one is a decision that never changes for a given person — whoever works for print wants SVG every time — so it is made once under Nutzereinstellungen (default PNG) instead of in every dialog.

Changed

  • Modal dialogs close without JavaScript. The X sits in a <form method="dialog">, which is the only way a browser closes a dialog on its own. Needed since a dialog can now be delivered open; without it the X would have been a button with no effect.

  • Modal dialogs carry their own toast layer, like the drawer already did. A dialog opened with showModal() lives in the browser's top layer and makes everything outside inert — a message in the layout's global region would be both covered and unusable (no closing, no pausing, so a conflict with WCAG 2.2.1). Concretely needed for the copy button in the new creation dialog.

2.0.1 — 2026-08-31

An account name is not unique — the places that name one now say which account is meant.

Changed

  • Accounts are named with their prefix wherever a link points at one. An account name is not unique — two customers may both be called Muster GmbH, and nothing stops an administrator from handing out the same name twice. The column Konto (admin oversight) and Angelegt von (link overview) therefore carry the account's prefix in brackets below the name; the Angelegt von line and the assignment dropdown on the link detail view carry it behind the name: Muster GmbH (xy). Without it, an administrator picking between two identically named entries was guessing — and what is being handed over is responsibility for a link, along with the notices about it.

    The prefix works as the distinguishing mark because every account is given one when it is created, including in the shared pool, where it never appears in an address. It is therefore the only short, always-present, always-unique thing to show.

2.0.0 — 2026-08-31

Shortlinks belong to the installation, not to the account that created them — and an optional per-account mode gives every account its own address namespace.

Added

  • A maximum link lifetime, and a default for it (Settings → Features). Operators could already forbid permanent links, but not cap how long a link may live: the four periods (1/7/30/90 days) and the preselection were hard-coded, and the preselection happened to be No expiry — the furthest-reaching of all options, applied whenever nobody opened the dropdown. Both are now settings. A maximum also rules out No expiry, otherwise it would be one click away from being bypassed; setting both at once is refused rather than silently resolved. The limit applies to newly chosen periods — existing links are not shortened, and Unchanged stays selectable on the edit form even when the remaining time exceeds the maximum. Otherwise saving a changed title would cut the lifetime of a link already in circulation, which hits its recipients, not the person editing.

  • A minimum length for link passwords (Settings → Features), default 8. It was min:4 in the validation rules, with no way to change it, while an account in the same application requires twelve. Existing links keep working — a stored hash cannot be measured, and a link in circulation would become unusable for its recipients. The rule applies when somebody sets a password, and the help text on the field names the number in force.

  • A switch for the preview page (Settings → Links, top of the page): show the target before every redirect. The per-link checkbox could only ever force a preview for a single link; an operator who wanted it as a house rule had no switch and had to empty the trusted-domain list as a side effect. The more specific reasons still win on the preview page itself — a visitor whose target redirects elsewhere continues to read that, not "this installation always shows this". Where the switch is on, the now-ineffective per-link checkbox says so.

  • A switch for the update check on Settings → System data. It only ever lived in SHORTLINK_UPDATE_CHECK, which stays the starting value; the operator's decision on the page overrides it in both directions. Where the check points (SHORTLINK_UPDATE_SOURCE) stays in the .env — which server this installation listens to is a matter of trust and does not belong in a form.

  • The three retention periods are now shown on Settings → Maintenance, with the values actually in force. Two of them limit personal data (the truncated IP in the click log, the recipient address in the send log) and lived exclusively in the .env; the page named the keys but never the values, so the operator answering a GDPR request could not look them up in the interface. Display only — they are still set in the .env.

Changed

  • Shortlinks now belong to the installation, not to the account that created them. Every signed-in account sees every link and can view, edit, send and delete it. created_by is kept and shown — in the overview (column Angelegt von, from xl up) and on the detail view — as an answer to "who do I ask about this", not as an ownership claim. The one thing still reserved for administrators is oversight: retiring a link and releasing it again.

    The rule lives in one place (App\Support\LinkAccess), because it answers two questions that must always agree — "does this link appear in my list" and "may I act on it". They used to be nine separate expressions; had they drifted, the result would be a list whose rows answer 403.

    For an existing installation nobody loses anything, but everybody sees more: after the update each account's overview shows what until now only its creator saw. That is the intended effect, not a side effect.

    The price, stated plainly: there are no graded per-link rights. Anyone who may edit can point a circulating link somewhere else — which carries further than deleting it, since a deleted link stops redirecting while a redirected one goes elsewhere.

  • Deleting an account no longer destroys its links. This is the consequence that would have hurt most if left alone. The old rule — a deleted account leaves no working redirects — was right while a link belonged to its creator. In a shared pool it is the most expensive version of the wrong behaviour: someone leaving would take the whole house's campaign links with them, addresses that are in print and that their colleagues use. The links now stay and keep redirecting; only the reference falls away (the created_by foreign key is nullOnDelete, so the database clears it), and the overview shows "—" instead of a name. Both confirmation dialogs — the operator's under Konto and the administrator's in the account list — say which of the two happens, because the same number means something entirely different in each case.

  • A per-account mode with its own address namespace (Settings → Features, top card). Off by default: the shared pool stays what a fresh and an existing installation get. Turned on, every account sees and manages only its own links, and new addresses carry the account's namespace — /xy/sommeraktion instead of /sommeraktion. Only that makes two accounts able to hold sommeraktion at the same time; separate pools without separate namespaces would have them fight over the same wished-for addresses while unable to see each other's links.

    The mode only mints, it never migrates. Both address forms resolve permanently, in either setting — an address that was once issued keeps working after switching and after switching back. Anything else would be a switch that breaks printed links, and then breaks them a second time on the way back. The namespace is therefore stored as a string on the link (slug_prefix, '' = global) and is deliberately not derived from the creator: a creator can change or fall away, a printed address cannot. The unique index moves from slug to (slug_prefix, slug); '' rather than NULL, because NULL != NULL in a unique index would have silently dropped the promise that an address is never issued twice.

    Every account is given a two-character namespace when it is created — 32 characters in the alphabet means 1024 of them, from the same reduced alphabet as generated slugs (no 0, 1, l, o, because addresses get read out and typed). Existing accounts are given one by the migration. The namespaces grow to three and four characters if it ever gets tight; lengths coexist, so no printed address ever breaks.

    Switching to the per-account mode hands links without a creator to the administrator who flipped the switch — otherwise they would be in service and visible to nobody, since claiming only exists in the shared pool.

  • Unfortunate namespaces are blocked, and an administrator can swap one out. A rolled two-character namespace can read as a statement — a party abbreviation, a far-right shorthand, an insult. There is no official list for this: profanity lists know fu and fck but no party abbreviation, and § 86a StGB is about symbols in context, not about a generated identifier. So both: a curated list in App\Support\Prefixes that is never handed out, and — because such a list is never complete and the person affected notices first — a way for an administrator to reissue one, with free suggestions rather than another blind roll.

    The old namespace is then blocked permanently, not released: giving it back to the pool would hand it to the next account and start the whole thing over. That is why a namespace without an account now has two distinct states — dead (account deleted, released once nothing depends on it) and blocked (swapped out, kept forever). Addresses already issued under the old one keep it and keep resolving; an account can end up holding addresses in two namespaces, which is exactly why the namespace lives on the link and not on the account.

  • A namespace outlives the account it belonged to, and the cleanup run releases it again when nothing depends on it. Deleting an account leaves the reservation standing with no user attached; releasing it while any link still lives under it — including a deleted one, whose address stays taken forever — would let a new account inherit xy and hand out an address that has already been sent. In practice what gets released are the namespaces of accounts that never created a link, which is exactly the point: since they are handed out at account creation, an invited account that was never used would otherwise hold one of the short namespaces forever.

  • An administrator can assign a link to another account, and the assignee is told by email. The counterpart to claiming: there somebody picks up an ownerless link themselves, which any account may do because it takes nothing from anyone. Here somebody decides about another account, so it is reserved for the operator — and unlike claiming it also works on a link that already has a creator, because nothing is obscured when the operator moves responsibility deliberately and the new creator hears about it. The occasion is usually a colleague who left and whose links somebody has to carry on.

    The assignment is saved before anything is sent: otherwise the reachability of a mail server would decide whether responsibility can be set at all. If the notice fails, the result says so, and the form warns beforehand when no mail is configured — a warning after the fact is one the operator forgets, because the action itself worked.

  • A switch for custom short addresses (Settings → Features), on by default. A wished-for address is convenient and, on an installation with several accounts, a distribution problem at the same time: whoever takes shop, kontakt or 2026 first has it forever, because an issued address is never released again. There is no way to undo that, so an operator has to be able to rule it out beforehand.

    Off means every link gets a random address and the field disappears from the form — not a disabled field, which would explain a possibility that does not exist here. The switch reaches the validation too, not just the markup: a wish submitted anyway is rejected, and the live check says so instead of answering "free". Existing wished-for addresses are untouched and keep working; their recipients cannot help it.

  • A link without a creator can be claimed. After an account is deleted its links keep working but have nobody attached — and that attachment is not paperwork: the notice that the target check has retired a link goes to the creator. Without one it goes nowhere, and the link fails silently. The overview marks such links ohne Anleger, and the detail view offers Link übernehmen, which records the claimer as the creator and nothing else.

    Deliberately a button and not automatic: the application could enter whoever edits the link next, and would thereby have made someone responsible who only fixed a target. A link that still has a creator cannot be taken over — claiming is tidying up, not appropriation, otherwise who put an address into the world could be obscured. No confirmed-email requirement either: nothing new is created and no right is added, and the state it replaces is "nobody is reachable".

  • The custom short address moved to the top of the create form, above the target URL. It used to sit behind the Options panel, so anyone who wanted one typed the target first, opened the panel and added the address afterwards — against their own intent — and anyone who did not open the panel never learned the option existed. The default is still automatic: the field is optional and empty, and empty still means a random address. The edit form is unchanged; there is no such field there, because a slug cannot be changed.

1.1.0 — 2026-08-25

One script that gets a server from nothing to almost-running, and keeps it current.

Added

  • shortlink-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 Shortlink 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 shortlink: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, so whoever could change the one could change the other in the same commit. 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 is one command: the shell has to have read it to the closing brace before anything inside runs. Measured on the real script at three cut-off points: without the braces a truncated copy executes its opening lines, with them not a single one.

Changed

  • The release notes now open with the two curl calls and 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. Both were done by hand for 1.1.0 and both caught something.

1.0.0 — 2026-08-25

First released version. Derived from Fileshare; the shortener domain is new.

Added

  • Initial project scaffold, derived from the Fileshare code base at commit 8b3e08e (see docs/HERKUNFT.txt). Accounts, two-factor authentication, invitations, settings, the installer, the editorial pages and the release process carry over; the file-sharing domain does not.
  • Short links. Create with a random 7-character slug or a custom one, optional title, expiry, click limit and password. A slug is stored lowercase and is never reused — deleted links stay as a row, so an address that was printed or sent out can never lead somewhere else later.
  • Resolution of /{slug} in one controller, shared with the editorial pages, with a fixed order: page before link. One list of reserved slugs, checked in both directions.
  • 302 redirect. Never 301: a permanently cached redirect never reaches frequent visitors again when the target changes, and the counter never sees them again either.
  • Click counting with bot detection. Link previews from chat and mail clients are logged with is_bot but neither counted nor charged against the click limit. Logged rather than discarded, because the detection stays a heuristic and a false positive has to remain visible afterwards.
  • Preview page for targets outside the trusted domains, with the reason stated. No automatic redirect and no anchor to the target: the full address is selectable text, and "continue" is a POST — an anchor would bypass the counter and give crawlers something to follow. The response carries Referrer-Policy: no-referrer.
  • Password protection comes before the preview page. The preview page names the target; shown first, the password would only be a delay.
  • Target check (shortlink:check-targets, second cron entry). Follows the redirect chain itself over at most five hops and validates the resolved IP addresses at every hop against private and reserved ranges — a public hostname may legitimately resolve to 10.0.0.5, and checking the name instead of the address would leave the service as a port scanner for its own network. Three results: ok, foreign_redirect, unknown. A timeout is not a finding.
  • Public Suffix List shipped as data (resources/public-suffix-list.dat, MPL 2.0), refreshable with shortlink:suffix-list — the only place this application reaches out on its own, and only when the command is run. Comparison is on the registrable domain, which cannot be derived by stripping labels: customer.co.uk would become co.uk.
  • Settings → Links: trusted target domains (one list; the installation's own domain is a pre-filled entry), behaviour for unverifiable targets, check interval, and two restriction switches — "only allow targets on released domains" and "retire affected links automatically", both off by default.
  • Operator oversight under /admin/links: every link of every account, sorted findings first, with exactly two actions — retire and release. Editing and deleting stay with the owner.
  • Mail to the link owner when a link is retired automatically, naming the target, the actual end of the redirect chain, and the fact that the operator can release it again.
  • IP addresses in the click log are truncated on write (IPv4 without the last octet, IPv6 with the last 80 bits zeroed). An address truncated only on display was stored in full beforehand.
  • A confirmed e-mail address is required to create links. Only that: existing links can still be edited, sent and deleted, and every settings page — including the operator's own — stays open. An account without a confirmed address is not a locked account; it just cannot put anything new on the operator's domain. The reason is that the address is the only way to reach a link's owner when something is wrong with it (see the mail sent on automatic retirement), and an address nobody ever proved is not a way.
  • A confirmation mail can be (re-)sent from the account page, for both open cases: a current address that was never confirmed, and a pending change whose link expired. One button for both, because for the person it is one action — which of the two applies is something the controller knows better than the form. Every request mints a new token and invalidates the previous link; the usual reason for pressing the button is that the old link had expired.
  • Guard tests for the frontend sources: every relative import in resources/js must resolve, and every init…() called at startup must exist. php -l does not read JavaScript and the test suite calls withoutVite(), so a broken import would otherwise surface only in the build stage — after the tests.

Changed

  • The retention run (shortlink:cleanup) has four categories instead of eight. A shortener has no files, so there are no orphaned files, no records without a file and no aborted upload remains. Expired links are retired, not removed: the address stays taken.

  • Deleting a link takes its click and send logs with it, via one deleting hook on the model. The database cascade only fires on a real DELETE, and since a link is only ever marked as deleted, the logs — truncated IP addresses and recipient addresses — would otherwise have been left behind unnoticed.

  • 18 migrations consolidated into one initial schema.

  • The installer no longer offers to set up "file encryption". It wrote a key that nothing read, and warned about losing files that do not exist.

  • The CLI installer names both cron entries and no longer mentions a queue worker. There is no background work in this application; QUEUE_CONNECTION=sync is enough.

  • The brand mark now shows a chain, not up/down arrows. The arrows were the symbol of the file service this project came from (see docs/HERKUNFT.txt) and promised a shortener the wrong thing. Both the reduced mark (public/favicon.svg) and the full one (resources/images/logo.svg) now carry Heroicons link, with the path data taken unchanged from the icon atom rather than redrawn.

    Placing it was measured, not eyeballed. In the favicon the glyph is larger than the arrows were (37 units of ink against 30 × 27) and still no tighter: the symbol runs diagonally, so its corners are empty, and what decides whether an icon looks cramped is the closest approach to the outline — that is 11.5 units, against the arrows' 11.3. In the full mark the chain had to move left and down, because its diagonal runs against the axis of the shell and crossed the line work where the arrows had sat.

  • The installer no longer declares the first administrator's address confirmed. It used to write email_verified_at on the assumption that whoever installs types their own address correctly. Through the pipeline nobody types it at all — it comes from a variable. A typo was invisible: the account worked, and only the mails went nowhere — invitations, password resets, and the notice about automatically retired links. The first administrator can now do everything except create links until they have confirmed the address, which means setting up mail delivery first. That is the same order this application needs anyway.

  • Two accessibility defects carried over from the Fileshare layout, both invisible in the browser: the skip link jumped to a <main> without tabindex="-1", which in several browsers moves the scroll position but not the focus — the next Tab went back into the header, so the link skipped nothing. And scroll-smooth was unconditional; it is now motion-safe:scroll-smooth, because a jump target does not have to glide, it has to arrive.

Security

  • Only http and https targets are accepted, and credentials in the URL are rejected rather than stripped: https://customer.com@evil.example/ leads to evil.example, but a human reads the part in front as the host.
  • Fixed in the inherited code before the first release: session()->boolean() does not exist on Illuminate\Session\Store. Because of short-circuit evaluation this affected password protected links only — they answered 500 instead of showing the form. A regression test now requests that page from the outside.

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