Skip to content

feat(redirect): serve a configurable not-found page, and add linkforty.config.json - #89

Merged
onamfc merged 3 commits into
mainfrom
feat/not-found-page
Oct 28, 2026
Merged

onamfc merged 3 commits into
mainfrom
feat/not-found-page

Conversation

@onamfc

@onamfc onamfc commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

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.

Layer Holds Examples
Environment variables Infrastructure and secrets DATABASE_URL, PORT, TRUST_PROXY, LINKFORTY_CONFIG (path to the file)
linkforty.config.json The instance's defaults and behavior, including text a visitor reads notFound (this PR)
organizations.settings Per-organization values, which take precedence launchpad, notFound

Type of Change

  • ✨ New feature (non-breaking change which adds functionality)
  • ✅ Test addition or update
  • 📝 Documentation update

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/" } }.

docker run -d \
  -e DATABASE_URL=postgresql://user:pass@host:5432/db \
  -v ./linkforty.config.json:/app/linkforty.config.json:ro \
  linkforty/core:latest

The first of these that is set wins:

  1. the notFound hook (code, for library users who need logic);
  2. the organization's settings.notFound, read by Core like the Launchpad settings;
  3. the config file's notFound section;
  4. the built-in page.

Which organization's settings apply:

  • The organization the request's host belongs to.
  • On a shared host, a live link that has nothing for this visitor (a targeting miss, no destination) uses its own organization's settings.
  • An unknown, expired or switched-off code on a shared host uses the instance's settings, so it looks like any unknown code.

Changes Made

  • src/lib/config.ts (new): loadConfig() reads LINKFORTY_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 throws ConfigError naming every bad setting, for example notFound.accentColor: must be a hex color such as #0f766e; notFound: unknown setting "titel".

  • src/index.ts: createServer({ config }) passes config.notFound to the redirect routes. An explicit redirect.notFoundDefaults in code takes precedence. The config module is exported.

  • examples/basic-server.ts (the Docker entry): loads the file, logs its path, and on a ConfigError prints 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.
    • Settings helpers: readNotFoundSettings() and readOrganizationNotFoundSettings(), which are lenient because a bad value stored in the database must not break a redirect, plus resolveNotFoundSettings().
    • 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: one sendNotFound() helper replaces the five JSON 404s:

    Case reason link passed to the hook
    Unknown, expired or inactive code unknown null
    Another organization's link on this host unknown null (not this host's row to hand out)
    Switched off by a non-abuse decision disabled the row
    Visitor outside the targeting rules not_targeted the row
    No destination for this device no_destination the row
    • Requests without Accept: text/html get the same JSON as before, even when a redirect is configured.
    • The page is sent with status 404, X-Robots-Tag: noindex, nofollow, Cache-Control: no-store and a CSP of default-src 'none'. A redirect is a 302 with Cache-Control: no-store.
    • The abuse 410 path is unchanged.
    • Click recording is unchanged.
  • New redirect options: notFoundDefaults (instance settings) and notFound(request, { reason, link, scope }) (a hook; returning null falls through to the settings).

  • Docs:

    • linkforty.config.example.json (also shipped in the npm package);
    • a "Configuration file" section in the README and DOCKER.md, with a table of every notFound setting;
    • a commented, read-only mount in docker-compose.yml, with a warning that Docker mounts an empty directory if the file is missing;
    • LINKFORTY_CONFIG in .env.example.

Testing

  • Tests pass locally (npm test): 606 passed. The database integration suites skip without DATABASE_URL.
  • New tests added for new functionality
  • Existing tests updated (if needed)
  • Type checking passes (npm run typecheck)

Test Coverage

  • src/lib/config.test.ts:
    • no file gives the defaults; the working-directory file and LINKFORTY_CONFIG are both read;
    • a missing named file, invalid JSON, a non-http(s) URL, a non-hex color, a button label without a URL, an unknown key, a wrong type and over-long text are each rejected with the setting named.
  • src/lib/not-found-page.test.ts: escaping, dropped URLs and colors, light-only background, Accept detection, 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 through fastify.inject():
    • every case gets the same page, byte for byte;
    • JSON clients are unchanged, even with a redirect configured;
    • the abuse 410 is untouched;
    • the hook receives the reason, the row and the scope, overrides the settings, and falls through when it returns null;
    • instance defaults and an instance redirect apply;
    • the host organization's settings apply, falling back to the instance;
    • a targeting miss on a shared host uses the link's organization, while a switched-off link there matches an unknown code byte for byte.
  • Manual check of the Docker entry script: a bad config exits 1 with every setting named, a missing named file exits 1, and a valid config logs Configuration: <path> and carries on to connect to the database.

Checklist

  • My code follows the project's code style
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings or errors
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have used conventional commit messages (e.g., feat:, fix:)

Breaking Changes

None.

  • Without a config file, everything behaves as before, except that browsers get the HTML page (or a configured redirect) instead of a JSON body for the same 404.
  • Requests that don't accept HTML get exactly the JSON they did before.
  • A config file is only read if one exists, so existing deployments don't start failing.

…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

codecov Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.40741% with 14 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/index.ts 0.00% 12 Missing ⚠️
src/lib/config.ts 98.43% 2 Missing ⚠️

📢 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.
@onamfc onamfc changed the title feat(redirect): serve an HTML not-found page to browsers, with a notFound hook feat(redirect): serve a configurable not-found page, and add linkforty.config.json Oct 28, 2026
@onamfc
onamfc merged commit 068f37d into main Oct 28, 2026
14 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 28, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant