Repository navigation
feat(redirect): serve a configurable not-found page, and add linkforty.config.json - #89
Merged
Merged
Conversation
…ound hook
A link that can't be served answered every visitor with a raw JSON body,
`{"error":"Link not found"}`. That covered an unknown code, an expired or
switched-off link, another organization's link on this host, a visitor
outside the link's targeting rules, and a link with no destination for the
visitor's device. A person who clicked an old campaign link or scanned an
old QR code saw JSON.
Browsers (requests accepting text/html) now get a small not-found page:
status 404, noindex, Cache-Control: no-store, and a content-security policy
that allows no script. Every one of those cases renders the same page, so an
expired or switched-off link still can't be told apart from a code that
never existed. Clients that don't accept HTML keep the JSON body unchanged.
A link withdrawn for abuse keeps its own 410 notice.
The new `notFound` redirect option lets a host return page content (title,
message, logo, accent and background colors, a button) or an http(s) URL to
redirect to. It receives the reason, the link row (null when the link is not
this host's to hand out) and the host's organization. A hook that throws is
logged and treated as null. The brand background applies in light mode only.
No click is recorded for any of these responses that didn't already record
one.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
The first version could only be customized through the notFound hook, a function passed to redirectRoutes. Anyone running the Docker image, which is configured only through environment variables, could not change the page or send visitors anywhere else. Settings are now read from, in order: 1. the notFound hook, when it returns something; 2. the organization's settings.notFound, on a host that belongs to it, or for a targeting miss or a link with no destination, the link's own organization. Read by Core itself, like the Launchpad settings, and held for a minute so unknown-code scans don't each cost a query; 3. the new notFoundDefaults option, which the Docker image fills from NOT_FOUND_* environment variables (notFoundSettingsFromEnv); 4. the built-in page. Each layer can set a redirect URL or the page's title, message, logo, colors and button. An unknown, expired or switched-off code on a shared host still uses the instance's page, so it stays indistinguishable from a code that never existed. ctaLabel/ctaUrl are renamed buttonLabel/buttonUrl before release. Documented in DOCKER.md, .env.example, docker-compose.yml and the README.
…ettings into it The previous commit configured the not-found page through eight NOT_FOUND_* environment variables, putting page text and colors in the environment. Nothing else in Core uses environment variables for content: they hold infrastructure and secrets. Core now follows the split most self-hosted platforms use: - environment variables: infrastructure and secrets (unchanged), plus LINKFORTY_CONFIG, the path to the config file; - linkforty.config.json: the instance's defaults and behavior, including text a visitor reads. Optional; read from LINKFORTY_CONFIG or the working directory. Validated strictly at startup: an invalid value or an unknown setting stops the server with the setting named; - organizations.settings: per-organization values, which take precedence. loadConfig() and parseConfig() are exported, and createServer() accepts the result as `config`. The Docker entry script loads it and exits with the error on a bad file. The not-found page is the file's first section, `notFound`, with the same fields as an organization's settings.notFound. Removes the NOT_FOUND_* environment variables and notFoundSettingsFromEnv() before release. Adds linkforty.config.example.json (shipped in the npm package), a commented mount in docker-compose.yml, and docs in DOCKER.md, the README and .env.example.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
When a short link can't be served, every visitor got a raw JSON body:
{"error":"Link not found"}. That covered an unknown code, an expired or switched-off link, another organization's link on this host, a visitor outside the link's targeting rules, and a link with no destination for the visitor's device. A person who clicked an old campaign link or scanned an old QR code saw JSON in their browser.Browsers now get a small HTML not-found page. Every one of those cases renders the same page, so an expired or switched-off link still can't be told apart from a code that never existed. API clients and scripts keep the JSON they get today.
To make the page configurable without writing code, this PR also introduces
linkforty.config.json, an optional config file for instance settings.DATABASE_URL,PORT,TRUST_PROXY,LINKFORTY_CONFIG(path to the file)linkforty.config.jsonnotFound(this PR)organizations.settingslaunchpad,notFoundType of Change
Configuring the not-found page
{ "notFound": { "title": "Link not found", "message": "This link doesn't exist or is no longer available.", "logoUrl": "https://example.com/logo.png", "accentColor": "#0f766e", "backgroundColor": "#f6f7f8", "buttonLabel": "Go to example.com", "buttonUrl": "https://example.com/" } }Or send every broken link to your site with
{ "notFound": { "redirectUrl": "https://example.com/" } }.The first of these that is set wins:
notFoundhook (code, for library users who need logic);settings.notFound, read by Core like the Launchpad settings;notFoundsection;Which organization's settings apply:
Changes Made
src/lib/config.ts(new):loadConfig()readsLINKFORTY_CONFIG, or else./linkforty.config.json. A file named explicitly must exist; the default one is optional.parseConfig()validates with Zod in strict mode, so a bad value or an unknown key throwsConfigErrornaming every bad setting, for examplenotFound.accentColor: must be a hex color such as #0f766e; notFound: unknown setting "titel".src/index.ts:createServer({ config })passesconfig.notFoundto the redirect routes. An explicitredirect.notFoundDefaultsin code takes precedence. The config module is exported.examples/basic-server.ts(the Docker entry): loads the file, logs its path, and on aConfigErrorprints it and exits 1 before connecting to the database.src/lib/not-found-page.ts(new):renderNotFoundPage(content). The page is styled like the existing withdrawn-link notice, follows the visitor's light/dark setting and runs no script. Text is escaped, URLs must be http(s), colors must be hex, and copy is length-capped. A background color applies in light mode only.readNotFoundSettings()andreadOrganizationNotFoundSettings(), which are lenient because a bad value stored in the database must not break a redirect, plusresolveNotFoundSettings().createOrganizationNotFoundSettingsLookup(). It reads an organization's settings for an unknown code on its host and holds each answer for 60 s, so scans of unknown codes don't each cost a query. A failed read isn't held.src/routes/redirect.ts: onesendNotFound()helper replaces the five JSON 404s:reasonlinkpassed to the hookunknownnullunknownnull(not this host's row to hand out)disablednot_targetedno_destinationAccept: text/htmlget the same JSON as before, even when a redirect is configured.X-Robots-Tag: noindex, nofollow,Cache-Control: no-storeand a CSP ofdefault-src 'none'. A redirect is a 302 withCache-Control: no-store.New redirect options:
notFoundDefaults(instance settings) andnotFound(request, { reason, link, scope })(a hook; returningnullfalls through to the settings).Docs:
linkforty.config.example.json(also shipped in the npm package);notFoundsetting;docker-compose.yml, with a warning that Docker mounts an empty directory if the file is missing;LINKFORTY_CONFIGin.env.example.Testing
npm test): 606 passed. The database integration suites skip withoutDATABASE_URL.npm run typecheck)Test Coverage
src/lib/config.test.ts:LINKFORTY_CONFIGare both read;src/lib/not-found-page.test.ts: escaping, dropped URLs and colors, light-only background,Acceptdetection, settings parsing, redirect-vs-page resolution, and the lookup's TTL (including that failures aren't held).src/routes/redirect.not-found.test.ts, driving the real plugin throughfastify.inject():null;Configuration: <path>and carries on to connect to the database.Checklist
feat:,fix:)Breaking Changes
None.