@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.
- package/README.md +66 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/config.d.ts +33 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +111 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/config.test.d.ts +2 -0
- package/dist/lib/config.test.d.ts.map +1 -0
- package/dist/lib/config.test.js +86 -0
- package/dist/lib/config.test.js.map +1 -0
- package/dist/lib/not-found-page.d.ts +106 -0
- package/dist/lib/not-found-page.d.ts.map +1 -0
- package/dist/lib/not-found-page.js +212 -0
- package/dist/lib/not-found-page.js.map +1 -0
- package/dist/lib/not-found-page.test.d.ts +2 -0
- package/dist/lib/not-found-page.test.d.ts.map +1 -0
- package/dist/lib/not-found-page.test.js +151 -0
- package/dist/lib/not-found-page.test.js.map +1 -0
- package/dist/routes/redirect.d.ts +21 -0
- package/dist/routes/redirect.d.ts.map +1 -1
- package/dist/routes/redirect.js +72 -7
- package/dist/routes/redirect.js.map +1 -1
- package/dist/routes/redirect.no-web-destination.test.js +2 -2
- package/dist/routes/redirect.no-web-destination.test.js.map +1 -1
- package/dist/routes/redirect.not-found.test.d.ts +2 -0
- package/dist/routes/redirect.not-found.test.d.ts.map +1 -0
- package/dist/routes/redirect.not-found.test.js +280 -0
- package/dist/routes/redirect.not-found.test.js.map +1 -0
- package/linkforty.config.example.json +11 -0
- package/package.json +3 -2
|
@@ -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 @@
|
|
|
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'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('<script>alert(1)</script>');
|
|
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;
|
|
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"}
|
package/dist/routes/redirect.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
990
|
+
return sendNotFound(request, reply, 'no_destination', link, hostScope);
|
|
926
991
|
}
|
|
927
992
|
// Build final URL with parameters
|
|
928
993
|
let finalUrl = redirectUrl;
|