@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
package/README.md
CHANGED
|
@@ -245,6 +245,33 @@ GET /:templateSlug/:shortCode
|
|
|
245
245
|
|
|
246
246
|
Automatically redirects users to the appropriate URL based on device type (iOS/Android/web), evaluates targeting rules, and tracks the click asynchronously.
|
|
247
247
|
|
|
248
|
+
**Not-found page:** When a link can't be served, a browser gets a small HTML page ("Link not found") with status 404, `noindex`, `Cache-Control: no-store` and a content-security policy that allows no script. That covers an unknown code, an expired or switched-off link, a link on another organization's host, a visitor outside the link's targeting rules, and a link with no destination for the visitor's device. All of these render the same page, so an expired link can't be told apart from a code that never existed. Clients that don't send `Accept: text/html` (API callers, scripts) keep the JSON body: `{ "error": "Link not found" }`, or `{ "error": "No destination URL configured for this link" }` for a link with no destination. A link withdrawn for abuse keeps its own 410 notice.
|
|
249
|
+
|
|
250
|
+
You can change the page or send browsers somewhere else instead, without writing code. The first of these that is set wins:
|
|
251
|
+
|
|
252
|
+
1. **The organization's settings**, `organizations.settings.notFound`, on a host that belongs to the organization. On a shared host, only a live link that has nothing for this visitor (a targeting miss, no destination) uses its organization's settings. An unknown, expired or switched-off code there uses the instance's.
|
|
253
|
+
|
|
254
|
+
```json
|
|
255
|
+
{
|
|
256
|
+
"notFound": {
|
|
257
|
+
"redirectUrl": "https://ridealert.example", // send browsers here (302); the rest is ignored
|
|
258
|
+
"title": "This ride link has expired",
|
|
259
|
+
"message": "Open the app to see current rides.",
|
|
260
|
+
"logoUrl": "https://cdn.example/logo.png",
|
|
261
|
+
"accentColor": "#0f766e", // the button
|
|
262
|
+
"backgroundColor": "#f6f7f8", // light mode only
|
|
263
|
+
"buttonLabel": "Get the app", // shown only with buttonUrl
|
|
264
|
+
"buttonUrl": "https://ridealert.example/app"
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Changes show within a minute.
|
|
270
|
+
2. **The instance's defaults**: the `notFound` section of `linkforty.config.json` (see [Configuration file](#configuration-file)), with the same fields.
|
|
271
|
+
3. **The built-in page.**
|
|
272
|
+
|
|
273
|
+
URLs must be http(s) and colors hex; anything else is dropped, and all text is escaped. The `notFound` option below decides in code ahead of all three.
|
|
274
|
+
|
|
248
275
|
**Mobile interstitial:** When a link has `appScheme` configured and a store fallback URL (iOS App Store or Google Play), mobile requests receive a smart interstitial page instead of a raw 302 redirect. The interstitial tries to open the app via URI scheme and falls back to the app store after 1.5 seconds. This handles the case where a 302 to a custom URI scheme fails silently when the app is not installed. URL fragments are preserved through the redirect, enabling patterns like E2E encryption where the decryption key lives in the fragment.
|
|
249
276
|
|
|
250
277
|
**Launchpad page (desktop):** A link to app-only content has nowhere to send a desktop visitor. When the chain resolves no web destination (link → template → workspace `appConfig`), a desktop request gets a hosted landing page instead of an error: the link's title, description and image (`ogTitle` / `ogDescription` / `ogImageUrl`, falling back to `title` / `description`), the app's icon and name, the official App Store / Google Play badges (embedded in the page, so it makes no third-party request), and a QR code so the visitor can finish on their phone. The page is `noindex`, sent with `Cache-Control: no-store` and a content-security policy that allows no script without a per-response nonce, and it never navigates on its own — no timers, no automatic scheme attempts.
|
|
@@ -380,6 +407,7 @@ interface ServerOptions {
|
|
|
380
407
|
trustProxy?: boolean | number; // Trust X-Forwarded-For when behind a proxy (default: false)
|
|
381
408
|
redirect?: RedirectRouteOptions; // See below
|
|
382
409
|
sdk?: SdkRouteOptions; // See below
|
|
410
|
+
config?: LinkFortyConfig; // linkforty.config.json, from loadConfig()
|
|
383
411
|
}
|
|
384
412
|
|
|
385
413
|
interface RedirectRouteOptions {
|
|
@@ -406,6 +434,24 @@ interface RedirectRouteOptions {
|
|
|
406
434
|
// association files on that host, so no installed app can claim its URLs.
|
|
407
435
|
// A hook that throws is logged and treated as false.
|
|
408
436
|
isWebLinkRequest?: (request) => Promise<boolean> | boolean;
|
|
437
|
+
// The instance's not-found page or redirect, used when the organization has
|
|
438
|
+
// no settings.notFound of its own. createServer() fills it from the
|
|
439
|
+
// `notFound` section of the config file (ServerOptions.config).
|
|
440
|
+
notFoundDefaults?: NotFoundSettings;
|
|
441
|
+
// Decide the not-found response in code, ahead of the organization's
|
|
442
|
+
// settings and notFoundDefaults: page content (title,
|
|
443
|
+
// message, logoUrl, accentColor, backgroundColor, buttonLabel, buttonUrl — all
|
|
444
|
+
// optional, all escaped; non-http(s) URLs and non-hex colors are dropped),
|
|
445
|
+
// a URL to 302 to (http(s) only), or null to fall through. Called only
|
|
446
|
+
// for requests that accept HTML, and never for a link withdrawn for abuse.
|
|
447
|
+
// `context.reason` is "unknown" | "disabled" | "not_targeted" |
|
|
448
|
+
// "no_destination"; `context.link` is the link row (null for "unknown");
|
|
449
|
+
// `context.scope` is the host's organization, if any. Use the reason to
|
|
450
|
+
// decide whose settings apply, not to vary the page: an expired link must
|
|
451
|
+
// look like a code that never existed. A hook that throws is logged and
|
|
452
|
+
// treated as null.
|
|
453
|
+
notFound?: (request, context: { reason, link, scope }) =>
|
|
454
|
+
Promise<NotFoundResolution | null> | NotFoundResolution | null;
|
|
409
455
|
}
|
|
410
456
|
|
|
411
457
|
interface SdkRouteOptions {
|
|
@@ -422,7 +468,26 @@ interface SdkRouteOptions {
|
|
|
422
468
|
|
|
423
469
|
Install matching only considers clicks from the installing device's own network (its IPv4 /24 or IPv6 /64), since no other click can reach the confidence threshold, so the attribution window holds however much traffic the server takes. It relies on the `idx_fingerprints_ip` index, which `initializeDatabase()` builds concurrently on first start so click writes are never blocked.
|
|
424
470
|
|
|
425
|
-
`renderLaunchpadPage()`, `defaultLaunchpadContent()` and `shouldServeLaunchpadOnDesktop()` are exported so a host can render the same page elsewhere (a settings preview, for instance).
|
|
471
|
+
`renderLaunchpadPage()`, `defaultLaunchpadContent()` and `shouldServeLaunchpadOnDesktop()` are exported so a host can render the same page elsewhere (a settings preview, for instance). `renderNotFoundPage()` is exported for the same reason, and for a host that answers some requests before they reach the redirect route.
|
|
472
|
+
|
|
473
|
+
### Configuration file
|
|
474
|
+
|
|
475
|
+
Core splits its configuration the way most self-hosted platforms do:
|
|
476
|
+
|
|
477
|
+
- **Environment variables** hold infrastructure and secrets: `DATABASE_URL`, `REDIS_URL`, `PORT`, `TRUST_PROXY`, and so on.
|
|
478
|
+
- **`linkforty.config.json`** holds the instance's defaults and behavior, including text a visitor reads. Today that's the `notFound` section; see [linkforty.config.example.json](linkforty.config.example.json) and [DOCKER.md](DOCKER.md#configuration-file) for every setting.
|
|
479
|
+
- **`organizations.settings`** holds per-organization values, which take precedence over the file where both apply.
|
|
480
|
+
|
|
481
|
+
The file is optional. Core reads the path in `LINKFORTY_CONFIG`, or `linkforty.config.json` in the working directory. A file with an invalid value or unknown setting stops the server at startup with the setting named, rather than starting with it ignored. The Docker image does this for you. When you create the server yourself, load it and pass it in:
|
|
482
|
+
|
|
483
|
+
```typescript
|
|
484
|
+
import { createServer, loadConfig } from '@linkforty/core';
|
|
485
|
+
|
|
486
|
+
const { config } = loadConfig(); // throws ConfigError on a bad file
|
|
487
|
+
const server = await createServer({ database: { url: process.env.DATABASE_URL }, config });
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
Options passed in code (such as `redirect.notFoundDefaults`) take precedence over the file.
|
|
426
491
|
|
|
427
492
|
### Running behind a reverse proxy
|
|
428
493
|
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { FastifyInstance } from 'fastify';
|
|
|
2
2
|
import { DatabaseOptions } from './lib/database.js';
|
|
3
3
|
import { type RedirectRouteOptions } from './routes/redirect.js';
|
|
4
4
|
import { type SdkRouteOptions } from './routes/sdk.js';
|
|
5
|
+
import type { LinkFortyConfig } from './lib/config.js';
|
|
5
6
|
/**
|
|
6
7
|
* Configuration options for creating a LinkForty server instance.
|
|
7
8
|
*/
|
|
@@ -20,6 +21,11 @@ export interface ServerOptions {
|
|
|
20
21
|
redirect?: RedirectRouteOptions;
|
|
21
22
|
/** Options for the SDK routes: install match scoping. */
|
|
22
23
|
sdk?: SdkRouteOptions;
|
|
24
|
+
/**
|
|
25
|
+
* The instance's settings from linkforty.config.json, as loadConfig() returns
|
|
26
|
+
* them. Options passed in code take precedence over the file.
|
|
27
|
+
*/
|
|
28
|
+
config?: LinkFortyConfig;
|
|
23
29
|
}
|
|
24
30
|
/**
|
|
25
31
|
* Create and configure a LinkForty Fastify server instance.
|
|
@@ -40,6 +46,8 @@ export * from './lib/link-lookup.js';
|
|
|
40
46
|
export * from './lib/portable/index.js';
|
|
41
47
|
export * from './lib/link-safety.js';
|
|
42
48
|
export * from './lib/launchpad.js';
|
|
49
|
+
export * from './lib/not-found-page.js';
|
|
50
|
+
export * from './lib/config.js';
|
|
43
51
|
export * from './lib/event-emitter.js';
|
|
44
52
|
export * from './types/index.js';
|
|
45
53
|
export type { RedirectRouteOptions } from './routes/redirect.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAgB,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAGnD,OAAO,EAAsB,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACxE,OAAO,EAAkB,KAAK,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAGjF,OAAO,EAAa,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAgB,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAGnD,OAAO,EAAsB,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACxE,OAAO,EAAkB,KAAK,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAGjF,OAAO,EAAa,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAMlE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEvD;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,KAAK,CAAC,EAAE;QACN,GAAG,EAAE,MAAM,CAAC;KACb,CAAC;IACF,IAAI,CAAC,EAAE;QACL,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;KAC3B,CAAC;IACF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,oJAAoJ;IACpJ,UAAU,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IAC9B,gFAAgF;IAChF,QAAQ,CAAC,EAAE,oBAAoB,CAAC;IAChC,yDAAyD;IACzD,GAAG,CAAC,EAAE,eAAe,CAAC;IACtB;;;OAGG;IACH,MAAM,CAAC,EAAE,eAAe,CAAC;CAC1B;AAED;;;;;;;;GAQG;AACH,wBAAsB,YAAY,CAAC,OAAO,GAAE,aAAkB,kTAoC7D;AAGD,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,yBAAyB,CAAC;AACxC,cAAc,sBAAsB,CAAC;AACrC,cAAc,oBAAoB,CAAC;AACnC,cAAc,yBAAyB,CAAC;AACxC,cAAc,iBAAiB,CAAC;AAChC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,YAAY,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AACjE,YAAY,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,eAAe,EAAE,SAAS,EAAE,aAAa,EAAE,cAAc,EAAE,QAAQ,EAAE,aAAa,EAAE,WAAW,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -40,7 +40,10 @@ export async function createServer(options = {}) {
|
|
|
40
40
|
// Routes
|
|
41
41
|
await fastify.register(healthRoutes);
|
|
42
42
|
await fastify.register(wellKnownRoutes);
|
|
43
|
-
await fastify.register(redirectRoutes,
|
|
43
|
+
await fastify.register(redirectRoutes, {
|
|
44
|
+
...options.redirect,
|
|
45
|
+
notFoundDefaults: options.redirect?.notFoundDefaults ?? options.config?.notFound,
|
|
46
|
+
});
|
|
44
47
|
await fastify.register(linkRoutes);
|
|
45
48
|
await fastify.register(analyticsRoutes);
|
|
46
49
|
await fastify.register(sdkRoutes, options.sdk ?? {});
|
|
@@ -59,6 +62,8 @@ export * from './lib/link-lookup.js';
|
|
|
59
62
|
export * from './lib/portable/index.js';
|
|
60
63
|
export * from './lib/link-safety.js';
|
|
61
64
|
export * from './lib/launchpad.js';
|
|
65
|
+
export * from './lib/not-found-page.js';
|
|
66
|
+
export * from './lib/config.js';
|
|
62
67
|
export * from './lib/event-emitter.js';
|
|
63
68
|
export * from './types/index.js';
|
|
64
69
|
export { redirectRoutes, linkRoutes, analyticsRoutes, sdkRoutes, webhookRoutes, templateRoutes, qrRoutes, previewRoutes, debugRoutes, wellKnownRoutes, healthRoutes } from './routes/index.js';
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,OAA4B,MAAM,SAAS,CAAC;AACnD,OAAO,IAAI,MAAM,eAAe,CAAC;AACjC,OAAO,KAAK,MAAM,gBAAgB,CAAC;AACnC,OAAO,EAAE,kBAAkB,EAAmB,MAAM,mBAAmB,CAAC;AACxE,OAAO,EAAE,cAAc,EAA6B,MAAM,sBAAsB,CAAC;AACjF,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAC/C,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,SAAS,EAAwB,MAAM,iBAAiB,CAAC;AAClE,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { NotFoundSettings } from './not-found-page.js';
|
|
2
|
+
/** File looked for in the working directory when LINKFORTY_CONFIG is unset. */
|
|
3
|
+
export declare const DEFAULT_CONFIG_FILE = "linkforty.config.json";
|
|
4
|
+
export interface LinkFortyConfig {
|
|
5
|
+
/** What a browser sees when a link can't be served (lib/not-found-page.ts). */
|
|
6
|
+
notFound?: NotFoundSettings;
|
|
7
|
+
}
|
|
8
|
+
export declare class ConfigError extends Error {
|
|
9
|
+
readonly path: string;
|
|
10
|
+
constructor(message: string, path: string);
|
|
11
|
+
}
|
|
12
|
+
/** Validate an already-parsed configuration object. `path` names it in errors. */
|
|
13
|
+
export declare function parseConfig(raw: unknown, path?: string): LinkFortyConfig;
|
|
14
|
+
/**
|
|
15
|
+
* Load the configuration file.
|
|
16
|
+
*
|
|
17
|
+
* Reads the file named by `options.path`, else by LINKFORTY_CONFIG, else
|
|
18
|
+
* `linkforty.config.json` in the working directory. A file named explicitly
|
|
19
|
+
* must exist; the default one is optional. Returns the file's path, or null
|
|
20
|
+
* when none was read.
|
|
21
|
+
*
|
|
22
|
+
* @throws ConfigError when the file can't be read, isn't JSON, or has an
|
|
23
|
+
* invalid or unknown setting.
|
|
24
|
+
*/
|
|
25
|
+
export declare function loadConfig(options?: {
|
|
26
|
+
path?: string;
|
|
27
|
+
env?: Record<string, string | undefined>;
|
|
28
|
+
cwd?: string;
|
|
29
|
+
}): {
|
|
30
|
+
config: LinkFortyConfig;
|
|
31
|
+
path: string | null;
|
|
32
|
+
};
|
|
33
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAE5D,+EAA+E;AAC/E,eAAO,MAAM,mBAAmB,0BAA0B,CAAC;AAE3D,MAAM,WAAW,eAAe;IAC9B,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,gBAAgB,CAAC;CAC7B;AAED,qBAAa,WAAY,SAAQ,KAAK;IAGlC,QAAQ,CAAC,IAAI,EAAE,MAAM;gBADrB,OAAO,EAAE,MAAM,EACN,IAAI,EAAE,MAAM;CAKxB;AAiDD,kFAAkF;AAClF,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,SAAsB,GAAG,eAAe,CAMrF;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CACxB,OAAO,GAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAO,GACtF;IAAE,MAAM,EAAE,eAAe,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAkBlD"}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The instance's configuration file, `linkforty.config.json`.
|
|
3
|
+
*
|
|
4
|
+
* Configuration is split the way most self-hosted platforms split it:
|
|
5
|
+
*
|
|
6
|
+
* - environment variables hold infrastructure and secrets: where the
|
|
7
|
+
* database is, which port to listen on, and the path to this file;
|
|
8
|
+
* - this file holds the instance's defaults and behavior, including text a
|
|
9
|
+
* visitor reads;
|
|
10
|
+
* - `organizations.settings` holds per-organization values, which take
|
|
11
|
+
* precedence over this file where both apply.
|
|
12
|
+
*
|
|
13
|
+
* The file is optional: without one every built-in default applies. A file that
|
|
14
|
+
* exists but is wrong stops the server at startup with a message naming the bad
|
|
15
|
+
* key. The operator wrote it on purpose, so a mistake should show immediately
|
|
16
|
+
* rather than as a page that quietly ignores them.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readFileSync } from 'fs';
|
|
19
|
+
import { resolve } from 'path';
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
/** File looked for in the working directory when LINKFORTY_CONFIG is unset. */
|
|
22
|
+
export const DEFAULT_CONFIG_FILE = 'linkforty.config.json';
|
|
23
|
+
export class ConfigError extends Error {
|
|
24
|
+
constructor(message, path) {
|
|
25
|
+
super(`${path}: ${message}`);
|
|
26
|
+
this.path = path;
|
|
27
|
+
this.name = 'ConfigError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const httpUrl = z
|
|
31
|
+
.string()
|
|
32
|
+
.trim()
|
|
33
|
+
.refine((value) => {
|
|
34
|
+
try {
|
|
35
|
+
const { protocol } = new URL(value);
|
|
36
|
+
return protocol === 'http:' || protocol === 'https:';
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}, 'must be an http(s) URL');
|
|
42
|
+
const hexColor = z
|
|
43
|
+
.string()
|
|
44
|
+
.trim()
|
|
45
|
+
.regex(/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i, 'must be a hex color such as #0f766e');
|
|
46
|
+
const notFoundSchema = z
|
|
47
|
+
.object({
|
|
48
|
+
redirectUrl: httpUrl.optional(),
|
|
49
|
+
title: z.string().trim().min(1).max(120).optional(),
|
|
50
|
+
message: z.string().trim().min(1).max(500).optional(),
|
|
51
|
+
logoUrl: httpUrl.optional(),
|
|
52
|
+
accentColor: hexColor.optional(),
|
|
53
|
+
backgroundColor: hexColor.optional(),
|
|
54
|
+
buttonLabel: z.string().trim().min(1).max(60).optional(),
|
|
55
|
+
buttonUrl: httpUrl.optional(),
|
|
56
|
+
})
|
|
57
|
+
.strict()
|
|
58
|
+
.refine((value) => !value.buttonLabel === !value.buttonUrl, {
|
|
59
|
+
message: 'buttonLabel and buttonUrl must be set together',
|
|
60
|
+
});
|
|
61
|
+
const configSchema = z
|
|
62
|
+
.object({
|
|
63
|
+
notFound: notFoundSchema.optional(),
|
|
64
|
+
})
|
|
65
|
+
.strict();
|
|
66
|
+
function describeIssue(issue) {
|
|
67
|
+
if (issue.code === 'unrecognized_keys') {
|
|
68
|
+
const where = issue.path.length ? `${issue.path.join('.')}: ` : '';
|
|
69
|
+
return `${where}unknown setting ${issue.keys.map((k) => `"${k}"`).join(', ')}`;
|
|
70
|
+
}
|
|
71
|
+
return issue.path.length ? `${issue.path.join('.')}: ${issue.message}` : issue.message;
|
|
72
|
+
}
|
|
73
|
+
/** Validate an already-parsed configuration object. `path` names it in errors. */
|
|
74
|
+
export function parseConfig(raw, path = DEFAULT_CONFIG_FILE) {
|
|
75
|
+
const result = configSchema.safeParse(raw);
|
|
76
|
+
if (!result.success) {
|
|
77
|
+
throw new ConfigError(result.error.issues.map(describeIssue).join('; '), path);
|
|
78
|
+
}
|
|
79
|
+
return result.data;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Load the configuration file.
|
|
83
|
+
*
|
|
84
|
+
* Reads the file named by `options.path`, else by LINKFORTY_CONFIG, else
|
|
85
|
+
* `linkforty.config.json` in the working directory. A file named explicitly
|
|
86
|
+
* must exist; the default one is optional. Returns the file's path, or null
|
|
87
|
+
* when none was read.
|
|
88
|
+
*
|
|
89
|
+
* @throws ConfigError when the file can't be read, isn't JSON, or has an
|
|
90
|
+
* invalid or unknown setting.
|
|
91
|
+
*/
|
|
92
|
+
export function loadConfig(options = {}) {
|
|
93
|
+
const env = options.env ?? process.env;
|
|
94
|
+
const named = options.path || env.LINKFORTY_CONFIG || '';
|
|
95
|
+
const path = resolve(options.cwd ?? process.cwd(), named || DEFAULT_CONFIG_FILE);
|
|
96
|
+
if (!existsSync(path)) {
|
|
97
|
+
if (named)
|
|
98
|
+
throw new ConfigError('file not found', path);
|
|
99
|
+
return { config: {}, path: null };
|
|
100
|
+
}
|
|
101
|
+
let raw;
|
|
102
|
+
try {
|
|
103
|
+
raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
const reason = error instanceof SyntaxError ? `not valid JSON (${error.message})` : 'could not be read';
|
|
107
|
+
throw new ConfigError(reason, path);
|
|
108
|
+
}
|
|
109
|
+
return { config: parseConfig(raw, path), path };
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.test.d.ts","sourceRoot":"","sources":["../../src/lib/config.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { ConfigError, loadConfig, parseConfig } from './config.js';
|
|
6
|
+
let dir;
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
dir = mkdtempSync(join(tmpdir(), 'linkforty-config-'));
|
|
9
|
+
});
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
rmSync(dir, { recursive: true, force: true });
|
|
12
|
+
});
|
|
13
|
+
function write(name, contents) {
|
|
14
|
+
const path = join(dir, name);
|
|
15
|
+
writeFileSync(path, typeof contents === 'string' ? contents : JSON.stringify(contents));
|
|
16
|
+
return path;
|
|
17
|
+
}
|
|
18
|
+
function errorFrom(fn) {
|
|
19
|
+
try {
|
|
20
|
+
fn();
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
expect(error).toBeInstanceOf(ConfigError);
|
|
24
|
+
return error;
|
|
25
|
+
}
|
|
26
|
+
throw new Error('expected a ConfigError');
|
|
27
|
+
}
|
|
28
|
+
describe('loadConfig', () => {
|
|
29
|
+
it('uses the defaults when there is no file', () => {
|
|
30
|
+
expect(loadConfig({ cwd: dir, env: {} })).toEqual({ config: {}, path: null });
|
|
31
|
+
});
|
|
32
|
+
it('reads linkforty.config.json from the working directory', () => {
|
|
33
|
+
const path = write('linkforty.config.json', { notFound: { title: 'Gone' } });
|
|
34
|
+
expect(loadConfig({ cwd: dir, env: {} })).toEqual({ config: { notFound: { title: 'Gone' } }, path });
|
|
35
|
+
});
|
|
36
|
+
it('reads the file named by LINKFORTY_CONFIG', () => {
|
|
37
|
+
const path = write('custom.json', { notFound: { redirectUrl: 'https://example.com/' } });
|
|
38
|
+
expect(loadConfig({ cwd: '/', env: { LINKFORTY_CONFIG: path } }).config).toEqual({
|
|
39
|
+
notFound: { redirectUrl: 'https://example.com/' },
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
it('fails when a named file is missing', () => {
|
|
43
|
+
const error = errorFrom(() => loadConfig({ cwd: dir, env: { LINKFORTY_CONFIG: 'missing.json' } }));
|
|
44
|
+
expect(error.message).toContain('missing.json: file not found');
|
|
45
|
+
});
|
|
46
|
+
it('fails on invalid JSON', () => {
|
|
47
|
+
write('linkforty.config.json', '{ "notFound": { title: "x" } }');
|
|
48
|
+
expect(errorFrom(() => loadConfig({ cwd: dir, env: {} })).message).toContain('not valid JSON');
|
|
49
|
+
});
|
|
50
|
+
it('trims values', () => {
|
|
51
|
+
write('linkforty.config.json', { notFound: { title: ' Gone ' } });
|
|
52
|
+
expect(loadConfig({ cwd: dir, env: {} }).config.notFound?.title).toBe('Gone');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('parseConfig', () => {
|
|
56
|
+
it('accepts every notFound setting', () => {
|
|
57
|
+
const notFound = {
|
|
58
|
+
redirectUrl: 'https://example.com/',
|
|
59
|
+
title: 'Gone',
|
|
60
|
+
message: 'Try the homepage.',
|
|
61
|
+
logoUrl: 'https://cdn.example.com/logo.png',
|
|
62
|
+
accentColor: '#0f766e',
|
|
63
|
+
backgroundColor: '#f6f7f8',
|
|
64
|
+
buttonLabel: 'Home',
|
|
65
|
+
buttonUrl: 'https://example.com/',
|
|
66
|
+
};
|
|
67
|
+
expect(parseConfig({ notFound })).toEqual({ notFound });
|
|
68
|
+
});
|
|
69
|
+
it.each([
|
|
70
|
+
[{ notFound: { redirectUrl: 'javascript:alert(1)' } }, 'notFound.redirectUrl: must be an http(s) URL'],
|
|
71
|
+
[{ notFound: { accentColor: 'teal' } }, 'notFound.accentColor: must be a hex color'],
|
|
72
|
+
[{ notFound: { buttonLabel: 'Home' } }, 'buttonLabel and buttonUrl must be set together'],
|
|
73
|
+
[{ notFound: { titel: 'Gone' } }, 'notFound: unknown setting "titel"'],
|
|
74
|
+
[{ notfound: {} }, 'unknown setting "notfound"'],
|
|
75
|
+
[{ notFound: { title: 42 } }, 'notFound.title'],
|
|
76
|
+
[{ notFound: { title: 'x'.repeat(121) } }, 'notFound.title'],
|
|
77
|
+
])('names the bad setting: %j', (raw, expected) => {
|
|
78
|
+
const error = errorFrom(() => parseConfig(raw, 'linkforty.config.json'));
|
|
79
|
+
expect(error.message).toContain('linkforty.config.json: ');
|
|
80
|
+
expect(error.message).toContain(expected);
|
|
81
|
+
});
|
|
82
|
+
it('rejects a file that is not an object', () => {
|
|
83
|
+
expect(() => parseConfig([])).toThrow(ConfigError);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
//# sourceMappingURL=config.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.test.js","sourceRoot":"","sources":["../../src/lib/config.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** Why the link couldn't be served. For the host's hook; the page never shows it. */
|
|
2
|
+
export type NotFoundReason =
|
|
3
|
+
/** No such code, an expired or switched-off link, or another organization's link on this host. */
|
|
4
|
+
'unknown'
|
|
5
|
+
/** Switched off by a decision that isn't about abuse. */
|
|
6
|
+
| 'disabled'
|
|
7
|
+
/** The visitor is outside the link's country, device or language rules. */
|
|
8
|
+
| 'not_targeted'
|
|
9
|
+
/** The link has no destination for this visitor's device. */
|
|
10
|
+
| 'no_destination';
|
|
11
|
+
/** Everything optional: an empty object renders the default page. */
|
|
12
|
+
export interface NotFoundContent {
|
|
13
|
+
title?: string;
|
|
14
|
+
message?: string;
|
|
15
|
+
/** http(s) only; anything else is dropped. */
|
|
16
|
+
logoUrl?: string;
|
|
17
|
+
/** `#rgb`, `#rrggbb` or with alpha; anything else is dropped. */
|
|
18
|
+
accentColor?: string;
|
|
19
|
+
backgroundColor?: string;
|
|
20
|
+
/** The button is shown only when both label and a valid http(s) URL are present. */
|
|
21
|
+
buttonLabel?: string;
|
|
22
|
+
buttonUrl?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Not-found settings, as an organization stores them in `settings.notFound` and
|
|
26
|
+
* as the instance sets them in linkforty.config.json. With `redirectUrl`, browsers
|
|
27
|
+
* are sent there (302) and the rest is ignored; without it, the rest is the page.
|
|
28
|
+
*/
|
|
29
|
+
export interface NotFoundSettings extends NotFoundContent {
|
|
30
|
+
/** http(s) only. */
|
|
31
|
+
redirectUrl?: string;
|
|
32
|
+
}
|
|
33
|
+
export type NotFoundResolution = {
|
|
34
|
+
kind: 'page';
|
|
35
|
+
content: NotFoundContent;
|
|
36
|
+
} | {
|
|
37
|
+
kind: 'redirect';
|
|
38
|
+
url: string;
|
|
39
|
+
};
|
|
40
|
+
export interface NotFoundContext {
|
|
41
|
+
reason: NotFoundReason;
|
|
42
|
+
/**
|
|
43
|
+
* The resolved link row, when there is one. Null for `unknown`, including a
|
|
44
|
+
* link that belongs to another organization: that row is not this host's to
|
|
45
|
+
* hand out.
|
|
46
|
+
*/
|
|
47
|
+
link: Record<string, any> | null;
|
|
48
|
+
/** The organization the request's host belongs to (lib/link-lookup.ts), if any. */
|
|
49
|
+
scope: string | null;
|
|
50
|
+
}
|
|
51
|
+
export declare const NOT_FOUND_DEFAULTS: {
|
|
52
|
+
readonly title: "Link not found";
|
|
53
|
+
readonly message: "This link doesn't exist or is no longer available.";
|
|
54
|
+
};
|
|
55
|
+
/** JSON bodies kept for clients that don't accept HTML. */
|
|
56
|
+
export declare const NOT_FOUND_JSON: {
|
|
57
|
+
readonly default: {
|
|
58
|
+
readonly error: "Link not found";
|
|
59
|
+
};
|
|
60
|
+
readonly no_destination: {
|
|
61
|
+
readonly error: "No destination URL configured for this link";
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
/** True when the request will render HTML — a browser navigation. */
|
|
65
|
+
export declare function wantsHtml(accept: string | string[] | undefined): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* The page's content-security policy. It runs no script at all, so none is
|
|
68
|
+
* allowed; images may come from any https origin because the logo is the host's.
|
|
69
|
+
*/
|
|
70
|
+
export declare function notFoundContentSecurityPolicy(): string;
|
|
71
|
+
/** A redirect target from the host's hook, or null when it isn't http(s). */
|
|
72
|
+
export declare function safeNotFoundRedirect(url: unknown): string | null;
|
|
73
|
+
/**
|
|
74
|
+
* Settings from stored JSON (an organization's `settings`): keeps the known
|
|
75
|
+
* string fields and returns null when none is set. Lenient on purpose, since a
|
|
76
|
+
* bad value in the database must not break a redirect; values are checked
|
|
77
|
+
* again where they are used. The config file is validated strictly instead
|
|
78
|
+
* (lib/config.ts).
|
|
79
|
+
*/
|
|
80
|
+
export declare function readNotFoundSettings(raw: unknown): NotFoundSettings | null;
|
|
81
|
+
/** An organization's not-found settings, from its `settings` column. */
|
|
82
|
+
export declare function readOrganizationNotFoundSettings(orgSettings: unknown): NotFoundSettings | null;
|
|
83
|
+
/** How long an organization's settings are reused before being read again. */
|
|
84
|
+
export declare const NOT_FOUND_SETTINGS_TTL_MS = 60000;
|
|
85
|
+
/**
|
|
86
|
+
* Read an organization's not-found settings, reusing each answer for a minute.
|
|
87
|
+
*
|
|
88
|
+
* Only an unknown code on an organization's host needs this: every other case
|
|
89
|
+
* already has the link row, which carries its organization's settings. Unknown
|
|
90
|
+
* codes are what scanners hit, so the answer is held rather than read per
|
|
91
|
+
* request. A change to the settings shows within NOT_FOUND_SETTINGS_TTL_MS.
|
|
92
|
+
*
|
|
93
|
+
* A factory rather than module state, so each registration (and each test) has
|
|
94
|
+
* its own. A failed read resolves to null and is not held.
|
|
95
|
+
*/
|
|
96
|
+
export declare function createOrganizationNotFoundSettingsLookup(deps: {
|
|
97
|
+
query: (sql: string, params: unknown[]) => Promise<{
|
|
98
|
+
rows: Array<Record<string, unknown>>;
|
|
99
|
+
}>;
|
|
100
|
+
onError?: (error: unknown) => void;
|
|
101
|
+
now?: () => number;
|
|
102
|
+
}): (organizationId: string) => Promise<NotFoundSettings | null>;
|
|
103
|
+
/** What settings ask for: a redirect when they name a valid URL, otherwise the page. */
|
|
104
|
+
export declare function resolveNotFoundSettings(settings: NotFoundSettings): NotFoundResolution;
|
|
105
|
+
export declare function renderNotFoundPage(content?: NotFoundContent): string;
|
|
106
|
+
//# sourceMappingURL=not-found-page.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"not-found-page.d.ts","sourceRoot":"","sources":["../../src/lib/not-found-page.ts"],"names":[],"mappings":"AAkCA,qFAAqF;AACrF,MAAM,MAAM,cAAc;AACxB,kGAAkG;AAChG,SAAS;AACX,yDAAyD;GACvD,UAAU;AACZ,2EAA2E;GACzE,cAAc;AAChB,6DAA6D;GAC3D,gBAAgB,CAAC;AAErB,qEAAqE;AACrE,MAAM,WAAW,eAAe;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8CAA8C;IAC9C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,oFAAoF;IACpF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;GAIG;AACH,MAAM,WAAW,gBAAiB,SAAQ,eAAe;IACvD,oBAAoB;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,MAAM,kBAAkB,GAC1B;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,eAAe,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtC,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,cAAc,CAAC;IACvB;;;;OAIG;IACH,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,IAAI,CAAC;IACjC,mFAAmF;IACnF,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,eAAO,MAAM,kBAAkB;;;CAGrB,CAAC;AAEX,2DAA2D;AAC3D,eAAO,MAAM,cAAc;;;;;;;CAGjB,CAAC;AAOX,qEAAqE;AACrE,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,GAAG,OAAO,CAGxE;AAED;;;GAGG;AACH,wBAAgB,6BAA6B,IAAI,MAAM,CAQtD;AAED,6EAA6E;AAC7E,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAEhE;AAuBD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,gBAAgB,GAAG,IAAI,CAQ1E;AAED,wEAAwE;AACxE,wBAAgB,gCAAgC,CAAC,WAAW,EAAE,OAAO,GAAG,gBAAgB,GAAG,IAAI,CAG9F;AAED,8EAA8E;AAC9E,eAAO,MAAM,yBAAyB,QAAS,CAAC;AAGhD;;;;;;;;;;GAUG;AACH,wBAAgB,wCAAwC,CAAC,IAAI,EAAE;IAC7D,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;KAAE,CAAC,CAAC;IAC7F,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB,GAAG,CAAC,cAAc,EAAE,MAAM,KAAK,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAiB/D;AAED,wFAAwF;AACxF,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,gBAAgB,GAAG,kBAAkB,CAKtF;AAED,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,eAAoB,GAAG,MAAM,CA4DxE"}
|