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

Nothing yet.

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