@linkforty/core 1.41.3 → 1.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,212 @@
1
+ /**
2
+ * The page a browser gets when a short link can't be served.
3
+ *
4
+ * Several different things end here: a code that never existed, one that expired
5
+ * or was switched off, a link on another organization's host, a visitor outside
6
+ * the link's targeting rules, and a link with nowhere to send this device. They
7
+ * all render the SAME page. An expired or switched-off link must not be told
8
+ * apart from a code that never existed (see the safety gate in
9
+ * routes/redirect.ts), and a page that varied its copy by reason would do exactly
10
+ * that. The reason decides whose settings apply, never what the page says.
11
+ *
12
+ * What the page says, or where the visitor is sent instead, comes from the first
13
+ * of these that answers:
14
+ *
15
+ * 1. the `notFound` hook, for a deployment that needs logic;
16
+ * 2. the organization's `settings.notFound`, read by Core itself;
17
+ * 3. the instance's defaults: the `notFound` section of linkforty.config.json
18
+ * (lib/config.ts), passed to the routes as `notFoundDefaults`;
19
+ * 4. the built-in page.
20
+ *
21
+ * The organization is the one the request's host belongs to. Failing that, for a
22
+ * live link that just has nothing for this visitor (a targeting miss, no
23
+ * destination), it is the link's own organization. An unknown, expired or
24
+ * switched-off code on a shared host has no organization to speak for it and gets
25
+ * the instance's page.
26
+ *
27
+ * A link withdrawn for abuse is not a not-found: it keeps its own 410 notice
28
+ * (generateBlockedLinkHTML in link-safety.ts) and never reaches this module.
29
+ *
30
+ * Clients that don't ask for HTML — API callers, scripts, health checks — keep
31
+ * the JSON body they have always had.
32
+ */
33
+ import { escapeHtml, safeHref } from './link-safety.js';
34
+ export const NOT_FOUND_DEFAULTS = {
35
+ title: 'Link not found',
36
+ message: "This link doesn't exist or is no longer available.",
37
+ };
38
+ /** JSON bodies kept for clients that don't accept HTML. */
39
+ export const NOT_FOUND_JSON = {
40
+ default: { error: 'Link not found' },
41
+ no_destination: { error: 'No destination URL configured for this link' },
42
+ };
43
+ const TITLE_MAX = 120;
44
+ const MESSAGE_MAX = 500;
45
+ const BUTTON_LABEL_MAX = 60;
46
+ const HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
47
+ /** True when the request will render HTML — a browser navigation. */
48
+ export function wantsHtml(accept) {
49
+ const value = Array.isArray(accept) ? accept.join(',') : accept ?? '';
50
+ return value.toLowerCase().includes('text/html');
51
+ }
52
+ /**
53
+ * The page's content-security policy. It runs no script at all, so none is
54
+ * allowed; images may come from any https origin because the logo is the host's.
55
+ */
56
+ export function notFoundContentSecurityPolicy() {
57
+ return [
58
+ "default-src 'none'",
59
+ "img-src https: data:",
60
+ "style-src 'unsafe-inline'",
61
+ "base-uri 'none'",
62
+ "form-action 'none'",
63
+ ].join('; ');
64
+ }
65
+ /** A redirect target from the host's hook, or null when it isn't http(s). */
66
+ export function safeNotFoundRedirect(url) {
67
+ return typeof url === 'string' ? safeHref(url) : null;
68
+ }
69
+ function text(value, max) {
70
+ if (typeof value !== 'string')
71
+ return null;
72
+ const trimmed = value.trim();
73
+ return trimmed ? trimmed.slice(0, max) : null;
74
+ }
75
+ function color(value) {
76
+ return typeof value === 'string' && HEX_COLOR.test(value.trim()) ? value.trim() : null;
77
+ }
78
+ const SETTING_KEYS = [
79
+ 'redirectUrl',
80
+ 'title',
81
+ 'message',
82
+ 'logoUrl',
83
+ 'accentColor',
84
+ 'backgroundColor',
85
+ 'buttonLabel',
86
+ 'buttonUrl',
87
+ ];
88
+ /**
89
+ * Settings from stored JSON (an organization's `settings`): keeps the known
90
+ * string fields and returns null when none is set. Lenient on purpose, since a
91
+ * bad value in the database must not break a redirect; values are checked
92
+ * again where they are used. The config file is validated strictly instead
93
+ * (lib/config.ts).
94
+ */
95
+ export function readNotFoundSettings(raw) {
96
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
97
+ return null;
98
+ const settings = {};
99
+ for (const key of SETTING_KEYS) {
100
+ const value = raw[key];
101
+ if (typeof value === 'string' && value.trim())
102
+ settings[key] = value.trim();
103
+ }
104
+ return Object.keys(settings).length > 0 ? settings : null;
105
+ }
106
+ /** An organization's not-found settings, from its `settings` column. */
107
+ export function readOrganizationNotFoundSettings(orgSettings) {
108
+ if (!orgSettings || typeof orgSettings !== 'object')
109
+ return null;
110
+ return readNotFoundSettings(orgSettings.notFound);
111
+ }
112
+ /** How long an organization's settings are reused before being read again. */
113
+ export const NOT_FOUND_SETTINGS_TTL_MS = 60000;
114
+ const NOT_FOUND_SETTINGS_MAX_ENTRIES = 1000;
115
+ /**
116
+ * Read an organization's not-found settings, reusing each answer for a minute.
117
+ *
118
+ * Only an unknown code on an organization's host needs this: every other case
119
+ * already has the link row, which carries its organization's settings. Unknown
120
+ * codes are what scanners hit, so the answer is held rather than read per
121
+ * request. A change to the settings shows within NOT_FOUND_SETTINGS_TTL_MS.
122
+ *
123
+ * A factory rather than module state, so each registration (and each test) has
124
+ * its own. A failed read resolves to null and is not held.
125
+ */
126
+ export function createOrganizationNotFoundSettingsLookup(deps) {
127
+ const held = new Map();
128
+ const now = deps.now ?? Date.now;
129
+ return async (organizationId) => {
130
+ const hit = held.get(organizationId);
131
+ if (hit && hit.until > now())
132
+ return hit.settings;
133
+ try {
134
+ const result = await deps.query('SELECT settings FROM organizations WHERE id = $1', [organizationId]);
135
+ const settings = readOrganizationNotFoundSettings(result.rows[0]?.settings);
136
+ if (held.size >= NOT_FOUND_SETTINGS_MAX_ENTRIES)
137
+ held.clear();
138
+ held.set(organizationId, { settings, until: now() + NOT_FOUND_SETTINGS_TTL_MS });
139
+ return settings;
140
+ }
141
+ catch (error) {
142
+ deps.onError?.(error);
143
+ return null;
144
+ }
145
+ };
146
+ }
147
+ /** What settings ask for: a redirect when they name a valid URL, otherwise the page. */
148
+ export function resolveNotFoundSettings(settings) {
149
+ const target = settings.redirectUrl ? safeNotFoundRedirect(settings.redirectUrl) : null;
150
+ if (target)
151
+ return { kind: 'redirect', url: target };
152
+ const { redirectUrl: _ignored, ...content } = settings;
153
+ return { kind: 'page', content };
154
+ }
155
+ export function renderNotFoundPage(content = {}) {
156
+ const title = escapeHtml(text(content.title, TITLE_MAX) ?? NOT_FOUND_DEFAULTS.title);
157
+ const message = escapeHtml(text(content.message, MESSAGE_MAX) ?? NOT_FOUND_DEFAULTS.message);
158
+ const logoUrl = typeof content.logoUrl === 'string' ? safeHref(content.logoUrl.trim()) : null;
159
+ const buttonUrl = typeof content.buttonUrl === 'string' ? safeHref(content.buttonUrl.trim()) : null;
160
+ const buttonLabel = text(content.buttonLabel, BUTTON_LABEL_MAX);
161
+ const accent = color(content.accentColor);
162
+ const background = color(content.backgroundColor);
163
+ // Written after the dark-mode block so the accent wins in both schemes. The
164
+ // background applies in light mode only: a brand background is almost always
165
+ // light, and behind the dark-mode card it would show as a bright frame.
166
+ const brandStyles = [
167
+ accent ? `.button { background:${accent}; color:#fff; }` : '',
168
+ background ? `@media (prefers-color-scheme: light) { body { background:${background}; } }` : '',
169
+ ].join('\n ');
170
+ const logo = logoUrl
171
+ ? `<img class="logo" src="${escapeHtml(logoUrl)}" alt="" referrerpolicy="no-referrer">\n `
172
+ : '';
173
+ const button = buttonUrl && buttonLabel
174
+ ? `\n <a class="button" href="${escapeHtml(buttonUrl)}" rel="noopener noreferrer">${escapeHtml(buttonLabel)}</a>`
175
+ : '';
176
+ return `<!doctype html>
177
+ <html lang="en">
178
+ <head>
179
+ <meta charset="utf-8">
180
+ <meta name="viewport" content="width=device-width,initial-scale=1">
181
+ <meta name="robots" content="noindex,nofollow">
182
+ <title>${title}</title>
183
+ <style>
184
+ :root { color-scheme: light dark; }
185
+ body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
186
+ background:#f6f7f8; color:#16191d; padding:24px; box-sizing:border-box;
187
+ font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }
188
+ .card { max-width:30rem; width:100%; background:#fff; border:1px solid #e3e6ea;
189
+ border-radius:10px; padding:28px; text-align:center; box-sizing:border-box; }
190
+ .logo { display:block; max-height:48px; max-width:60%; margin:0 auto 18px; }
191
+ h1 { margin:0 0 10px; font-size:1.25rem; line-height:1.3; }
192
+ p { margin:0; color:#5b636d; }
193
+ .button { display:inline-block; margin-top:20px; padding:10px 18px; border-radius:8px;
194
+ background:#16191d; color:#fff; text-decoration:none; font-weight:600; }
195
+ @media (prefers-color-scheme: dark) {
196
+ body { background:#14171a; color:#e8eaed; }
197
+ .card { background:#1d2126; border-color:#2c3238; }
198
+ p { color:#98a1ab; }
199
+ .button { background:#e8eaed; color:#14171a; }
200
+ }
201
+ ${brandStyles}
202
+ </style>
203
+ </head>
204
+ <body>
205
+ <main class="card">
206
+ ${logo}<h1>${title}</h1>
207
+ <p>${message}</p>${button}
208
+ </main>
209
+ </body>
210
+ </html>`;
211
+ }
212
+ //# sourceMappingURL=not-found-page.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"not-found-page.js","sourceRoot":"","sources":["../../src/lib/not-found-page.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=not-found-page.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"not-found-page.test.d.ts","sourceRoot":"","sources":["../../src/lib/not-found-page.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,151 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { NOT_FOUND_DEFAULTS, NOT_FOUND_SETTINGS_TTL_MS, createOrganizationNotFoundSettingsLookup, readNotFoundSettings, readOrganizationNotFoundSettings, resolveNotFoundSettings, notFoundContentSecurityPolicy, renderNotFoundPage, safeNotFoundRedirect, wantsHtml, } from './not-found-page.js';
3
+ describe('wantsHtml', () => {
4
+ it('is true for a browser navigation', () => {
5
+ expect(wantsHtml('text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8')).toBe(true);
6
+ });
7
+ it('is false for JSON clients, wildcards and a missing header', () => {
8
+ expect(wantsHtml('application/json')).toBe(false);
9
+ expect(wantsHtml('*/*')).toBe(false);
10
+ expect(wantsHtml(undefined)).toBe(false);
11
+ });
12
+ it('reads a repeated header', () => {
13
+ expect(wantsHtml(['application/json', 'text/html'])).toBe(true);
14
+ });
15
+ });
16
+ describe('renderNotFoundPage', () => {
17
+ it('renders the default copy with no content', () => {
18
+ const html = renderNotFoundPage();
19
+ expect(html).toContain(`<title>${NOT_FOUND_DEFAULTS.title}</title>`);
20
+ expect(html).toContain('doesn&#39;t exist or is no longer available.');
21
+ expect(html).not.toContain('<a ');
22
+ expect(html).not.toContain('<img');
23
+ });
24
+ it('runs no script', () => {
25
+ expect(renderNotFoundPage({ title: 'x' })).not.toContain('<script');
26
+ expect(notFoundContentSecurityPolicy()).not.toContain('script-src');
27
+ expect(notFoundContentSecurityPolicy()).toContain("default-src 'none'");
28
+ });
29
+ it('renders host content', () => {
30
+ const html = renderNotFoundPage({
31
+ title: 'Nothing here',
32
+ message: 'Try our homepage.',
33
+ logoUrl: 'https://cdn.example.com/logo.png',
34
+ accentColor: '#0d9488',
35
+ backgroundColor: '#fafafa',
36
+ buttonLabel: 'Go home',
37
+ buttonUrl: 'https://example.com/',
38
+ });
39
+ expect(html).toContain('<h1>Nothing here</h1>');
40
+ expect(html).toContain('<p>Try our homepage.</p>');
41
+ expect(html).toContain('src="https://cdn.example.com/logo.png"');
42
+ expect(html).toContain('href="https://example.com/"');
43
+ expect(html).toContain('>Go home</a>');
44
+ expect(html).toContain('background:#0d9488');
45
+ expect(html).toContain('background:#fafafa');
46
+ });
47
+ it('applies the brand background in light mode only', () => {
48
+ const html = renderNotFoundPage({ backgroundColor: '#ecfdf5' });
49
+ expect(html).toContain('@media (prefers-color-scheme: light) { body { background:#ecfdf5; } }');
50
+ expect(html.match(/background:#ecfdf5/g)).toHaveLength(1);
51
+ });
52
+ it('escapes host copy', () => {
53
+ const html = renderNotFoundPage({ title: '<script>alert(1)</script>', message: '"><img src=x>' });
54
+ expect(html).not.toContain('<script>alert(1)');
55
+ expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
56
+ expect(html).not.toContain('"><img src=x>');
57
+ });
58
+ it('drops non-http URLs', () => {
59
+ const html = renderNotFoundPage({
60
+ logoUrl: 'javascript:alert(1)',
61
+ buttonLabel: 'Click',
62
+ buttonUrl: 'javascript:alert(1)',
63
+ });
64
+ expect(html).not.toContain('javascript:');
65
+ expect(html).not.toContain('<a ');
66
+ expect(html).not.toContain('<img');
67
+ });
68
+ it('needs both a label and a URL for the button', () => {
69
+ expect(renderNotFoundPage({ buttonUrl: 'https://example.com/' })).not.toContain('<a ');
70
+ expect(renderNotFoundPage({ buttonLabel: 'Go' })).not.toContain('<a ');
71
+ });
72
+ it('drops colors that are not hex', () => {
73
+ const html = renderNotFoundPage({ accentColor: 'red;}body{display:none', backgroundColor: 'url(x)' });
74
+ expect(html).not.toContain('display:none');
75
+ expect(html).not.toContain('url(x)');
76
+ });
77
+ it('treats blank copy as absent', () => {
78
+ expect(renderNotFoundPage({ title: ' ' })).toContain(`<h1>${NOT_FOUND_DEFAULTS.title}</h1>`);
79
+ });
80
+ it('caps long copy', () => {
81
+ const html = renderNotFoundPage({ title: 'x'.repeat(1000) });
82
+ expect(html).not.toContain('x'.repeat(121));
83
+ });
84
+ });
85
+ describe('safeNotFoundRedirect', () => {
86
+ it('accepts http(s)', () => {
87
+ expect(safeNotFoundRedirect('https://example.com/a?b=1')).toBe('https://example.com/a?b=1');
88
+ });
89
+ it('rejects everything else', () => {
90
+ expect(safeNotFoundRedirect('javascript:alert(1)')).toBeNull();
91
+ expect(safeNotFoundRedirect('/relative')).toBeNull();
92
+ expect(safeNotFoundRedirect(undefined)).toBeNull();
93
+ });
94
+ });
95
+ describe('readNotFoundSettings', () => {
96
+ it('keeps known, non-empty string fields', () => {
97
+ expect(readNotFoundSettings({ title: ' Hi ', message: '', logoUrl: 42, extra: 'x', redirectUrl: 'https://a.example' }))
98
+ .toEqual({ title: 'Hi', redirectUrl: 'https://a.example' });
99
+ });
100
+ it('is null when nothing is set', () => {
101
+ expect(readNotFoundSettings({})).toBeNull();
102
+ expect(readNotFoundSettings(null)).toBeNull();
103
+ expect(readNotFoundSettings(['title'])).toBeNull();
104
+ });
105
+ it('reads an organization settings column', () => {
106
+ expect(readOrganizationNotFoundSettings({ notFound: { title: 'Org' }, launchpad: {} })).toEqual({ title: 'Org' });
107
+ expect(readOrganizationNotFoundSettings({ launchpad: {} })).toBeNull();
108
+ });
109
+ });
110
+ describe('resolveNotFoundSettings', () => {
111
+ it('redirects when the settings name a valid URL', () => {
112
+ expect(resolveNotFoundSettings({ redirectUrl: 'https://example.com/', title: 'x' }))
113
+ .toEqual({ kind: 'redirect', url: 'https://example.com/' });
114
+ });
115
+ it('renders the page when the redirect is not http(s)', () => {
116
+ expect(resolveNotFoundSettings({ redirectUrl: 'javascript:alert(1)', title: 'x' }))
117
+ .toEqual({ kind: 'page', content: { title: 'x' } });
118
+ });
119
+ });
120
+ describe('createOrganizationNotFoundSettingsLookup', () => {
121
+ it('holds an answer for the TTL, then reads again', async () => {
122
+ let clock = 0;
123
+ const query = vi.fn(async () => ({ rows: [{ settings: { notFound: { title: 'Org' } } }] }));
124
+ const lookup = createOrganizationNotFoundSettingsLookup({ query, now: () => clock });
125
+ expect(await lookup('org-a')).toEqual({ title: 'Org' });
126
+ await lookup('org-a');
127
+ expect(query).toHaveBeenCalledTimes(1);
128
+ clock += NOT_FOUND_SETTINGS_TTL_MS + 1;
129
+ await lookup('org-a');
130
+ expect(query).toHaveBeenCalledTimes(2);
131
+ });
132
+ it('holds a "no settings" answer too', async () => {
133
+ const query = vi.fn(async () => ({ rows: [] }));
134
+ const lookup = createOrganizationNotFoundSettingsLookup({ query });
135
+ expect(await lookup('org-a')).toBeNull();
136
+ await lookup('org-a');
137
+ expect(query).toHaveBeenCalledTimes(1);
138
+ });
139
+ it('does not hold a failed read', async () => {
140
+ const onError = vi.fn();
141
+ const query = vi
142
+ .fn()
143
+ .mockRejectedValueOnce(new Error('db down'))
144
+ .mockResolvedValueOnce({ rows: [{ settings: { notFound: { title: 'Org' } } }] });
145
+ const lookup = createOrganizationNotFoundSettingsLookup({ query, onError });
146
+ expect(await lookup('org-a')).toBeNull();
147
+ expect(onError).toHaveBeenCalledOnce();
148
+ expect(await lookup('org-a')).toEqual({ title: 'Org' });
149
+ });
150
+ });
151
+ //# sourceMappingURL=not-found-page.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"not-found-page.test.js","sourceRoot":"","sources":["../../src/lib/not-found-page.test.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,6 @@
1
1
  import { FastifyInstance, FastifyRequest } from 'fastify';
2
2
  import { type LaunchpadContent, type LaunchpadContentContext, type LaunchpadSettings } from '../lib/launchpad.js';
3
+ import { type NotFoundContext, type NotFoundResolution, type NotFoundSettings } from '../lib/not-found-page.js';
3
4
  /**
4
5
  * Detect iOS in-app browsers where Universal Links don't fire.
5
6
  * These browsers use WKWebView which bypasses the Universal Links mechanism.
@@ -86,6 +87,26 @@ export interface RedirectRouteOptions {
86
87
  * claim its URLs either. A hook that throws is logged and treated as false.
87
88
  */
88
89
  isWebLinkRequest?: (request: FastifyRequest) => Promise<boolean> | boolean;
90
+ /**
91
+ * The instance's not-found page, or a URL to send browsers to instead, used
92
+ * when the organization has no `settings.notFound` of its own
93
+ * (lib/not-found-page.ts). createServer() fills it from the `notFound`
94
+ * section of linkforty.config.json.
95
+ */
96
+ notFoundDefaults?: NotFoundSettings;
97
+ /**
98
+ * Decide the not-found response in code, ahead of the organization's settings
99
+ * and `notFoundDefaults`: page content, a URL to redirect to, or null to fall
100
+ * through to them. Called only for requests that accept HTML; other clients
101
+ * keep the JSON 404. Not called for a link withdrawn for abuse, which keeps its
102
+ * own notice.
103
+ *
104
+ * `context.reason` is for the host's own decisions. Whatever the host returns,
105
+ * return the same thing for every reason it treats alike: an expired link must
106
+ * not be told apart from a code that never existed. A hook that throws is
107
+ * logged and treated as null.
108
+ */
109
+ notFound?: (request: FastifyRequest, context: NotFoundContext) => Promise<NotFoundResolution | null> | NotFoundResolution | null;
89
110
  }
90
111
  export declare function redirectRoutes(fastify: FastifyInstance, options?: RedirectRouteOptions): Promise<void>;
91
112
  //# sourceMappingURL=redirect.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"redirect.d.ts","sourceRoot":"","sources":["../../src/routes/redirect.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAiB1D,OAAO,EASL,KAAK,gBAAgB,EACrB,KAAK,uBAAuB,EAC5B,KAAK,iBAAiB,EACvB,MAAM,qBAAqB,CAAC;AAK7B;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAa5D;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAiBhE;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,KAAK,GAAG,SAAS,EACzB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,UAAU,EAAE,MAAM,GAAG,IAAI,EACzB,cAAc,EAAE,MAAM,GAAG,IAAI,GAC5B;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAexC;AAKD;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAMlD;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAiB5E;AAmID,MAAM,WAAW,oBAAoB;IACnC;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,SAAS,CAAC,EAAE;QACV;;;;WAIG;QACH,cAAc,CAAC,EAAE,CACf,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EACzB,QAAQ,EAAE,iBAAiB,EAC3B,OAAO,EAAE,uBAAuB,KAC7B,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,GAAG,gBAAgB,GAAG,IAAI,CAAC;QAChE;;;;;;WAMG;QACH,SAAS,CAAC,EAAE,MAAM,CAAC;KACpB,CAAC;IACF;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC;CAC5E;AAED,wBAAsB,cAAc,CAClC,OAAO,EAAE,eAAe,EACxB,OAAO,GAAE,oBAAyB,iBAmyBnC"}
1
+ {"version":3,"file":"redirect.d.ts","sourceRoot":"","sources":["../../src/routes/redirect.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAiB1D,OAAO,EASL,KAAK,gBAAgB,EACrB,KAAK,uBAAuB,EAC5B,KAAK,iBAAiB,EACvB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAUL,KAAK,eAAe,EAEpB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,EACtB,MAAM,0BAA0B,CAAC;AAKlC;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAa5D;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAiBhE;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,KAAK,GAAG,SAAS,EACzB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,UAAU,EAAE,MAAM,GAAG,IAAI,EACzB,cAAc,EAAE,MAAM,GAAG,IAAI,GAC5B;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAexC;AAKD;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAMlD;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAiB5E;AAmID,MAAM,WAAW,oBAAoB;IACnC;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,SAAS,CAAC,EAAE;QACV;;;;WAIG;QACH,cAAc,CAAC,EAAE,CACf,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EACzB,QAAQ,EAAE,iBAAiB,EAC3B,OAAO,EAAE,uBAAuB,KAC7B,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,GAAG,gBAAgB,GAAG,IAAI,CAAC;QAChE;;;;;;WAMG;QACH,SAAS,CAAC,EAAE,MAAM,CAAC;KACpB,CAAC;IACF;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC;IAC3E;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IACpC;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,EAAE,CACT,OAAO,EAAE,cAAc,EACvB,OAAO,EAAE,eAAe,KACrB,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,GAAG,kBAAkB,GAAG,IAAI,CAAC;CACrE;AAED,wBAAsB,cAAc,CAClC,OAAO,EAAE,eAAe,EACxB,OAAO,GAAE,oBAAyB,iBAi3BnC"}
@@ -8,6 +8,7 @@ import { emitClickEvent } from '../lib/event-emitter.js';
8
8
  import { classifyBot, edgeBotSignal } from '../lib/bot-detection.js';
9
9
  import { evaluateLinkSafetyDecision, blockCauseIsAbuse, generateWarningLinkHTML, generateBlockedLinkHTML, createOwnerSuspensionSelect, escapeHtml, safeSchemeHref, } from '../lib/link-safety.js';
10
10
  import { createLaunchpadNonce, defaultLaunchpadContent, launchpadContentSecurityPolicy, readLaunchpadLinkMode, readLaunchpadSettings, renderLaunchpadPage, shouldServeLaunchpadOnDesktop, shouldServeLaunchpadOnMobile, } from '../lib/launchpad.js';
11
+ import { NOT_FOUND_JSON, createOrganizationNotFoundSettingsLookup, notFoundContentSecurityPolicy, readNotFoundSettings, readOrganizationNotFoundSettings, renderNotFoundPage, resolveNotFoundSettings, safeNotFoundRedirect, wantsHtml, } from '../lib/not-found-page.js';
11
12
  /** Longest query string the Launchpad page carries into its QR code and og:url. */
12
13
  const MAX_PAGE_URL_SEARCH = 512;
13
14
  /**
@@ -299,6 +300,68 @@ export async function redirectRoutes(fastify, options = {}) {
299
300
  fastify.log.warn({ err: error }, 'Bare code ownership check failed');
300
301
  }
301
302
  }
303
+ /**
304
+ * Answer a link that can't be served. Browsers get the not-found page (or the
305
+ * host's redirect); everything else gets the JSON body it always has. Records
306
+ * nothing: whether a click was already written is up to the caller.
307
+ */
308
+ const notFoundDefaults = readNotFoundSettings(options.notFoundDefaults);
309
+ const lookupOrganizationNotFoundSettings = createOrganizationNotFoundSettingsLookup({
310
+ query: (sql, params) => db.query(sql, params),
311
+ onError: (err) => fastify.log.warn({ err }, 'Reading not-found settings failed; using the instance page'),
312
+ });
313
+ /**
314
+ * The settings that speak for this not-found: the host's organization, or for
315
+ * a live link with nothing for this visitor, the link's own organization;
316
+ * then the instance's. See lib/not-found-page.ts.
317
+ */
318
+ async function notFoundSettingsFor(reason, link, scope) {
319
+ const linkSpeaks = reason === 'not_targeted' || reason === 'no_destination';
320
+ const organizationId = scope ?? (linkSpeaks ? link?.organization_id ?? null : null);
321
+ if (organizationId) {
322
+ const own = link && link.organization_id === organizationId
323
+ ? readOrganizationNotFoundSettings(link.org_settings)
324
+ : await lookupOrganizationNotFoundSettings(organizationId);
325
+ if (own)
326
+ return own;
327
+ }
328
+ return notFoundDefaults;
329
+ }
330
+ async function sendNotFound(request, reply, reason, link, scope) {
331
+ if (!wantsHtml(request.headers.accept)) {
332
+ return reply
333
+ .status(404)
334
+ .send(reason === 'no_destination' ? NOT_FOUND_JSON.no_destination : NOT_FOUND_JSON.default);
335
+ }
336
+ let resolution = null;
337
+ if (options.notFound) {
338
+ try {
339
+ resolution = (await options.notFound(request, { reason, link, scope })) ?? null;
340
+ }
341
+ catch (err) {
342
+ fastify.log.error({ err, reason }, 'notFound hook threw; using the configured page');
343
+ }
344
+ }
345
+ if (!resolution) {
346
+ const settings = await notFoundSettingsFor(reason, link, scope);
347
+ if (settings)
348
+ resolution = resolveNotFoundSettings(settings);
349
+ }
350
+ if (resolution?.kind === 'redirect') {
351
+ const target = safeNotFoundRedirect(resolution.url);
352
+ if (target) {
353
+ return reply.header('Cache-Control', 'no-store').redirect(302, target);
354
+ }
355
+ fastify.log.warn({ reason }, 'Not-found redirect is not http(s); using the default page');
356
+ }
357
+ return reply
358
+ .status(404)
359
+ .header('X-Robots-Tag', 'noindex, nofollow')
360
+ .header('Cache-Control', 'no-store')
361
+ .header('Content-Security-Policy', notFoundContentSecurityPolicy())
362
+ .type('text/html')
363
+ .send(renderNotFoundPage(resolution?.kind === 'page' ? resolution.content : {}));
364
+ }
302
365
  // Helper function to handle the actual redirect logic
303
366
  async function handleRedirect(request, reply, shortCode, templateSlug) {
304
367
  let linkData = null;
@@ -360,7 +423,7 @@ export async function redirectRoutes(fastify, options = {}) {
360
423
  if (result.rows.length === 0) {
361
424
  if (scope)
362
425
  await logOtherOrganizationCode(shortCode, scope);
363
- return reply.status(404).send({ error: 'Link not found' });
426
+ return sendNotFound(request, reply, 'unknown', null, hostScope);
364
427
  }
365
428
  linkData = JSON.stringify(result.rows[0]);
366
429
  // Cache for 5 minutes if Redis is available
@@ -378,7 +441,9 @@ export async function redirectRoutes(fastify, options = {}) {
378
441
  // URL form names them. Checked after the cache read, which is shared by hosts.
379
442
  if (hostScope && link.organization_id !== hostScope) {
380
443
  logOtherOrganizationLink(shortCode, hostScope);
381
- return reply.status(404).send({ error: 'Link not found' });
444
+ // Indistinguishable from a code that doesn't exist, and the row is not
445
+ // this host's to hand to the hook.
446
+ return sendNotFound(request, reply, 'unknown', null, hostScope);
382
447
  }
383
448
  // Safety gate, applied after the cache read so it covers cached rows too.
384
449
  //
@@ -417,7 +482,7 @@ export async function redirectRoutes(fastify, options = {}) {
417
482
  .type('text/html')
418
483
  .send(generateBlockedLinkHTML());
419
484
  }
420
- return reply.status(404).send({ error: 'Link not found' });
485
+ return sendNotFound(request, reply, 'disabled', link, hostScope);
421
486
  }
422
487
  if (safety.outcome === 'warn') {
423
488
  // No click is recorded here. A warning view is not a click on the link, and
@@ -464,7 +529,7 @@ export async function redirectRoutes(fastify, options = {}) {
464
529
  }
465
530
  // If targeting rules exist but user doesn't match, return 404
466
531
  if (!isTargeted) {
467
- return reply.status(404).send({ error: 'Link not found' });
532
+ return sendNotFound(request, reply, 'not_targeted', link, hostScope);
468
533
  }
469
534
  }
470
535
  // Asked once per click: the async click record and the response below
@@ -900,8 +965,8 @@ export async function redirectRoutes(fastify, options = {}) {
900
965
  * No destination at any level of the chain.
901
966
  *
902
967
  * On desktop this is a real person who clicked a real link, so serve a page
903
- * rather than a JSON error body. Mobile keeps the 404: the branches above
904
- * already offer a store URL to a device that could install the app, so
968
+ * rather than a JSON error body. Mobile gets the not-found page: the branches
969
+ * above already offer a store URL to a device that could install the app, so
905
970
  * reaching here means the link has nothing at all.
906
971
  *
907
972
  * Unlike the warning page earlier in this handler, this does NOT suppress
@@ -922,7 +987,7 @@ export async function redirectRoutes(fastify, options = {}) {
922
987
  androidUrl,
923
988
  }));
924
989
  }
925
- return reply.status(404).send({ error: 'No destination URL configured for this link' });
990
+ return sendNotFound(request, reply, 'no_destination', link, hostScope);
926
991
  }
927
992
  // Build final URL with parameters
928
993
  let finalUrl = redirectUrl;