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.jsonand the release note now name the project's website. The documentation is published there as pages rendered from this repository's own Markdown files, soINSTALL.mdand 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 sizelg, the optional custom address takes half the width with the options panel beside it, andShortlink anlegenspans 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) onatoms/input.twigandmolecules/form-field.twig—tallfor a field that has to line up with a control beside it,lgfor the one field a form is aiming at.command/commandforonatoms/button.twig— a dialog trigger does not have to be a whole cta-card.cliponmolecules/card.twig(defaulttrue, unchanged behaviour). Onfalsethe 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
footerblock was defined — andcard.twigdefines it itself so that anembedcan 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
k7for thenkthat 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
xyand hand out/xy/sommeraktiona 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:
jdfor „John Doe",mgfor „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 throughStr::ascii()— a table, noticonv('ASCII//TRANSLIT'), which returns something different depending on the host's locale.landoare the case with no good answer: they are missing from the alphabet because they are misread as1and0— 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" becomesim. 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 Schmidtwould givess), 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 whoseAPP_URLwas never corrected — the factoryhttp://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 readhttp://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 frontAlready 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_URLis 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_URLwithout a scheme (APP_URL=go.kunde.de) counts as not configured, and nothing is guessed:https://would be wrong on an installation without TLS andhttp://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, readsgo.kunde.deunderneath 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_URLand 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-listclears 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\Csvrather thanfputcsv(), 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 INTOso it is a closed state — a plaincopy()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 aREPEATABLE READsnapshot so table 12 cannot see a newer state than table 3.Built in PHP, without
mysqldumpand without a shell call — the same reasonApp\Support\SystemInfoavoids one: on hardened and shared hostsexec/shell_execare disabled andmysqldumpoften 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 withclick_logs. WithDB_PREFIXset, 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 theAPP_KEY.two_factor_secretandtwo_factor_recovery_codesare encrypted with it; restoring the database without the matching.envproduces 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.mdonly — 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-facefor 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 thepreloadlist 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
mdup — 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
lgup, 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
titleand 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-onlytext and as a tooltip, colour is only ever an addition to the shape, and both are covered by tests. - „Kurz-Adresse" is now „URL", and the column has a fixed width from
-
organisms/data-table.twigtakes aniconper column — the heading then shows the symbol instead of the word, withlabelkept as tooltip and screen-reader text — andalign: 'center'next to the existing'right', for columns holding a number.
Added
molecules/state-icon.twig— one state as an icon with its wording assr-onlyandtitle, for columns with more than two states (boolean-state.twigremains 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\LinkAccessalone. 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. SeeACCESSIBILITY.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.twigtakes aspacingprop (defaultmt-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-bodyis the body text,--e-headingthe headings,--e-labelthe field labels,--e-card-linethe card border,--e-buttonthe button surface,--e-navthe header bar,--e-table-headthe table head,--e-input-linethe field border. Every one of them points at the palette by default, so a theme that ignores this layer behaves exactly as before.
What this fixes is not cosmetic. Body text, headings, form labels, help text, table cells, menu entries, card borders, table borders, secondary buttons and badges all hung on the same two variables —
--c-inkand its derived--c-border. Body text is black or white in almost every theme; card borders and headings usually are not. An operator who wanted "headings in our blue, body text black" had no way to say it: changing the text colour moved eight other things with it. Now it is one line::root { --e-heading: #1d4ed8; }A fixed value applies in both modes — inheritance from the palette is cut at that point. Repeat it under
.darkif it should differ there, and check the contrast in both. -
Hover is graded. A shared pair
--e-hover/--e-on-hoveris the default for every inverting hover surface, and each interactive element has its own on top of it (--e-button-hover,--e-nav-hover,--e-menu-hover,--e-card-hover,--e-dialog-hover,--e-toast-hover,--e-subnav-hover, plus the secondary button). One value changes every hover; another changes only the button. -
Form fields separate text from border.
--c-field-inkused to be both, so a grey border with black text was not expressible. The new set is--e-input,--e-on-input,--e-input-lineand--e-input-hint(placeholder), plus--e-input-checkfor the checked state of a checkbox or radio. -
tests/Unit/DesignTokensTest.php— a guard over the token graph, run from PHP because every failure mode here is silent. It checks that every--e-…token is wired to a utility (an unwired token is a promise the interface does not keep: the operator sets a value and nothing happens), that every wiring points at a token that exists (the typo that leaves a utility with no colour), that the comments intokens.cssare balanced, and that no template reaches back to the palette. 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 fromnpm run build.
Changed
-
Templates now name the element, not the palette.
text-inkbecametext-body,text-heading,text-label,text-help,text-on-table,text-on-nav,text-on-menuand so on, depending on what the text actually is;border-inkbecameborder-card-lineorborder-table-line;bg-brandbecamebg-button,bg-accentor 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.cssso that a theme uploaded under 2.1 keeps working, including one that sets--color-field,--color-field-ink,--color-field-muted,--color-on-overlayor--color-on-overlay-ink: those five are read as the default of the corresponding element token and still take precedence. They are no longer allowed in a template, and the new test enforces that. -
Icon actions use the secondary button's tokens instead of a set of their own. An icon action is a secondary button, and two token sets for the same weight would have drifted apart.
Fixed
- Editorial pages were unreadable in dark mode. The typography plugin ships its own grey
palette (
--tw-prose-*) which knows neither this theme nor the dark mode unlessdark:prose-invertis set — and it was not. Body text on pages from the database rendered atoklch(37% 0 0)on black, roughly 2.7:1, below the WCAG 1.4.3 threshold. The plugin's variables are now bound to the element tokens, so editorial content follows the theme and the mode like everything else.prose-neutralwas dropped from the markup: it set exactly those variables back to grey.
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-coderenders 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 inApp\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 brokencomposer installon 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 (
openon 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— not403: 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:4in 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_byis kept and shown — in the overview (column Angelegt von, fromxlup) 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_byforeign key isnullOnDelete, 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/sommeraktioninstead of/sommeraktion. Only that makes two accounts able to holdsommeraktionat 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 fromslugto(slug_prefix, slug);''rather thanNULL, becauseNULL != NULLin 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
fuandfckbut no party abbreviation, and § 86a StGB is about symbols in context, not about a generated identifier. So both: a curated list inApp\Support\Prefixesthat 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
xyand 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,kontaktor2026first 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.shfrom the freshly downloaded package — the order of an update belongs to the version being installed, not to the one being replaced. Anything else means it stops without downloading or overwriting anything: a mistyped path must not clear out somebody else's directory.It deliberately stops short of
shortlink:installand prints what is left to do. A database password and the first administrator's password passed on a command line would sit in the process list and in the shell history.The script lives in the repository, not in the package: it has to be able to fetch a version it does not know about yet. Fetch it with
curl -fLO, read it, then run it — piping a script into a shell runs code nobody has seen, and on a dropped connection runs it half way.It carries no checksum of itself, and that is deliberate: the only place to publish one would be the same repository the script comes from, 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
curlcalls and the checksum check, instead of starting attar -xzfand quietly assuming the archive is already on the server. How the package gets there was the one step nobody had written down —INSTALL.mdsaid "unpack the package on the server" and then only showedgit clone. -
INSTALL.md, section 1 is now "Dateien auf den Server bringen" and names three ways in order of preference: the setup script, the manualcurlfrom the package registry with the checksum check, andgit clonefor development. The chapter "Aktualisieren" leads with the same one-line call — an operator who jumps straight to that chapter would otherwise never learn the script exists. -
RELEASE.mdgained two steps before the tag: 2b names, file by file, every place that spells out a version in prose, and 2c dry-runs the release note, the package self-check and the extraction of the previous version. 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(seedocs/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. 302redirect. Never301: 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_botbut 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 carriesReferrer-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 to10.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 withshortlink: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.ukwould becomeco.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/jsmust resolve, and everyinit…()called at startup must exist.php -ldoes not read JavaScript and the test suite callswithoutVite(), 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
deletinghook on the model. The database cascade only fires on a realDELETE, 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=syncis 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 Heroiconslink, 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_aton 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>withouttabindex="-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. Andscroll-smoothwas unconditional; it is nowmotion-safe:scroll-smooth, because a jump target does not have to glide, it has to arrive.
Security
- Only
httpandhttpstargets are accepted, and credentials in the URL are rejected rather than stripped:https://customer.com@evil.example/leads toevil.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 onIlluminate\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.
Version links
Quelle dieser Seite:
CHANGELOG.md
im Repository. Sie wird beim Bauen daraus erzeugt — Änderungen gehören in die
Markdown-Datei, nicht in diese Seite.