@dytsou/shorten-url 2.4.3 → 2.4.5
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 +165 -166
- package/config/config.example.js +22 -3
- package/config/config.js +128 -0
- package/docs/about.md +360 -0
- package/docs/api/index.html +1 -1
- package/docs/api/openapi.yaml +449 -75
- package/docs/flagship.md +58 -0
- package/docs/index.html +2 -2
- package/docs/plans/2026-09-06-001-refactor-worker-hosted-react-assets-plan.md +308 -0
- package/frontend/about-template.html +124 -0
- package/frontend/index.html +17 -0
- package/frontend/package.json +20 -0
- package/frontend/pnpm-lock.yaml +1129 -0
- package/frontend/src/App.jsx +1264 -0
- package/frontend/src/api.js +272 -0
- package/frontend/src/local-flag-mock.js +55 -0
- package/frontend/src/main.jsx +4 -0
- package/frontend/src/styles/app.css +2253 -0
- package/frontend/static/favicon.svg +5 -0
- package/frontend/vite.config.js +34 -0
- package/package.json +7 -6
- package/src/lib/access.js +241 -1
- package/src/lib/assets.js +58 -0
- package/src/lib/endpoints.js +33 -8
- package/src/lib/feature-toggle.js +464 -0
- package/src/lib/flag-management.js +137 -0
- package/src/lib/flag-routes.js +155 -0
- package/src/lib/flagship.js +269 -0
- package/src/lib/observability.js +34 -0
- package/src/lib/responses.js +39 -3
- package/src/lib/runtime-config.js +90 -0
- package/src/lib/safety.js +15 -0
- package/src/lib/shortener.js +2 -2
- package/src/lib/worker-handler.js +122 -0
- package/src/worker.js +6 -0
- package/src/worker.example.js +0 -65
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
|
|
2
|
+
<title id="title">Shorten URL</title>
|
|
3
|
+
<rect width="64" height="64" rx="12" fill="#d8ff54"/>
|
|
4
|
+
<path d="M25 22h-3a10 10 0 0 0 0 20h3M39 22h3a10 10 0 0 1 0 20h-3M24 32h16" fill="none" stroke="#111c1d" stroke-linecap="round" stroke-linejoin="round" stroke-width="6"/>
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { defineConfig } from "vite";
|
|
3
|
+
import react from "@vitejs/plugin-react";
|
|
4
|
+
import { marked } from "marked";
|
|
5
|
+
|
|
6
|
+
const docsDirectory = new URL("../docs/", import.meta.url);
|
|
7
|
+
|
|
8
|
+
function siteDocsAssets() {
|
|
9
|
+
return {
|
|
10
|
+
name: "site-docs-assets",
|
|
11
|
+
apply: "build",
|
|
12
|
+
async generateBundle() {
|
|
13
|
+
const [aboutTemplate, aboutMarkdown, apiIndexHtml, openApiSpec] = await Promise.all([
|
|
14
|
+
readFile(new URL("about-template.html", import.meta.url), "utf8"),
|
|
15
|
+
readFile(new URL("about.md", docsDirectory), "utf8"),
|
|
16
|
+
readFile(new URL("api/index.html", docsDirectory), "utf8"),
|
|
17
|
+
readFile(new URL("api/openapi.yaml", docsDirectory), "utf8"),
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const projectGuide = aboutMarkdown.replace(/^# URL Shortener$/m, "## URL Shortener");
|
|
21
|
+
const aboutHtml = aboutTemplate.replace("<!-- ABOUT_CONTENT -->", marked.parse(projectGuide));
|
|
22
|
+
|
|
23
|
+
this.emitFile({ type: "asset", fileName: "about.html", source: aboutHtml });
|
|
24
|
+
this.emitFile({ type: "asset", fileName: "api/index.html", source: apiIndexHtml });
|
|
25
|
+
this.emitFile({ type: "asset", fileName: "api/openapi.yaml", source: openApiSpec });
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default defineConfig({
|
|
31
|
+
base: "/",
|
|
32
|
+
publicDir: "static",
|
|
33
|
+
plugins: [react(), siteDocsAssets()],
|
|
34
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dytsou/shorten-url",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.5",
|
|
4
4
|
"description": "A modern, fast URL shortener built with Cloudflare Workers and a responsive web interface",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/worker.js",
|
|
@@ -27,23 +27,24 @@
|
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"prettier": "^3.7.4",
|
|
29
29
|
"vite": "7.3.6",
|
|
30
|
-
"vitest": "^
|
|
30
|
+
"vitest": "^5.0.0",
|
|
31
31
|
"wrangler": "^4.120.0"
|
|
32
32
|
},
|
|
33
33
|
"files": [
|
|
34
34
|
"src/worker.js",
|
|
35
|
-
"src/worker.example.js",
|
|
36
35
|
"src/lib/",
|
|
37
36
|
"config/",
|
|
38
37
|
"docs/",
|
|
38
|
+
"frontend/",
|
|
39
39
|
"README.md",
|
|
40
40
|
"LICENSE"
|
|
41
41
|
],
|
|
42
42
|
"scripts": {
|
|
43
|
-
"
|
|
44
|
-
"
|
|
43
|
+
"build": "pnpm --dir frontend build",
|
|
44
|
+
"dev": "pnpm build && wrangler dev --assets ./frontend/dist",
|
|
45
|
+
"deploy": "pnpm build && wrangler deploy",
|
|
45
46
|
"logs": "wrangler tail",
|
|
46
|
-
"preview": "wrangler dev",
|
|
47
|
+
"preview": "pnpm build && wrangler dev --assets ./frontend/dist",
|
|
47
48
|
"publish:npmjs": "pnpm publish --registry https://registry.npmjs.org",
|
|
48
49
|
"publish:github": "pnpm publish --registry https://npm.pkg.github.com",
|
|
49
50
|
"format": "prettier --write .",
|
package/src/lib/access.js
CHANGED
|
@@ -1,5 +1,245 @@
|
|
|
1
|
-
|
|
1
|
+
function allowedHosts(env) {
|
|
2
|
+
return new Set(
|
|
3
|
+
String(env?.ACCESS_ALLOWED_HOSTS || "")
|
|
4
|
+
.split(",")
|
|
5
|
+
.map((host) => host.trim().toLowerCase())
|
|
6
|
+
.filter(Boolean)
|
|
7
|
+
);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isAllowedAccessHost(request, env) {
|
|
11
|
+
const hosts = allowedHosts(env);
|
|
12
|
+
return hosts.size === 0 || hosts.has(new URL(request.url).hostname.toLowerCase());
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function hasPassedAccess(request, env) {
|
|
16
|
+
if (!isAllowedAccessHost(request, env)) return false;
|
|
2
17
|
const jwt = request.headers.get("Cf-Access-Jwt-Assertion");
|
|
3
18
|
const email = request.headers.get("Cf-Access-Authenticated-User-Email");
|
|
4
19
|
return Boolean(jwt && email);
|
|
5
20
|
}
|
|
21
|
+
|
|
22
|
+
const textEncoder = new TextEncoder();
|
|
23
|
+
const jwksCache = new Map();
|
|
24
|
+
const JWKS_TTL_MS = 5 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
function decodeBase64Url(value) {
|
|
27
|
+
const normalized = value.replaceAll("-", "+").replaceAll("_", "/");
|
|
28
|
+
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
|
|
29
|
+
const binary = atob(padded);
|
|
30
|
+
return Uint8Array.from(binary, (char) => char.codePointAt(0));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function decodeJson(value) {
|
|
34
|
+
return JSON.parse(new TextDecoder().decode(decodeBase64Url(value)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function issuerFor(env) {
|
|
38
|
+
if (env?.CF_ACCESS_ISSUER) return env.CF_ACCESS_ISSUER.replace(/\/$/, "");
|
|
39
|
+
if (env?.CF_ACCESS_TEAM_DOMAIN) {
|
|
40
|
+
const domain = env.CF_ACCESS_TEAM_DOMAIN.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
41
|
+
return `https://${domain}${domain.endsWith(".cloudflareaccess.com") ? "" : ".cloudflareaccess.com"}`;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isObjectRecord(value) {
|
|
47
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function decodeAccessToken(token, env) {
|
|
51
|
+
const segments = String(token).split(".");
|
|
52
|
+
if (segments.length !== 3) return null;
|
|
53
|
+
const [encodedHeader, encodedPayload, encodedSignature] = segments;
|
|
54
|
+
const issuer = issuerFor(env);
|
|
55
|
+
const audience = env?.CF_ACCESS_AUDIENCE;
|
|
56
|
+
const jwksUrl = env?.CF_ACCESS_JWKS_URL || (issuer ? `${issuer}/cdn-cgi/access/certs` : null);
|
|
57
|
+
if (!encodedHeader || !encodedPayload || !encodedSignature || !issuer || !audience || !jwksUrl) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const header = decodeJson(encodedHeader);
|
|
63
|
+
const payload = decodeJson(encodedPayload);
|
|
64
|
+
if (!isObjectRecord(header) || !isObjectRecord(payload)) return null;
|
|
65
|
+
if (header.alg !== "RS256" || typeof header.kid !== "string") return null;
|
|
66
|
+
return {
|
|
67
|
+
encodedHeader,
|
|
68
|
+
encodedPayload,
|
|
69
|
+
encodedSignature,
|
|
70
|
+
header,
|
|
71
|
+
payload,
|
|
72
|
+
issuer,
|
|
73
|
+
audience,
|
|
74
|
+
jwksUrl,
|
|
75
|
+
};
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function hasValidAccessClaims({ payload, issuer, audience }) {
|
|
82
|
+
const now = Math.floor(Date.now() / 1000);
|
|
83
|
+
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
84
|
+
if (audiences.some((value) => typeof value !== "string")) return false;
|
|
85
|
+
if (payload.iss !== issuer || !audiences.includes(audience)) return false;
|
|
86
|
+
if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp) || payload.exp <= now) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
if (
|
|
90
|
+
payload.nbf !== undefined &&
|
|
91
|
+
(typeof payload.nbf !== "number" || !Number.isFinite(payload.nbf) || payload.nbf > now)
|
|
92
|
+
) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function loadJwks(jwksUrl, fetchImpl) {
|
|
99
|
+
let keys = jwksCache.get(jwksUrl);
|
|
100
|
+
if (keys && keys.expiresAt > Date.now()) return keys;
|
|
101
|
+
|
|
102
|
+
const controller = new AbortController();
|
|
103
|
+
const timeout = setTimeout(() => controller.abort(), 2000);
|
|
104
|
+
try {
|
|
105
|
+
const response = await fetchImpl(jwksUrl, {
|
|
106
|
+
headers: { accept: "application/json" },
|
|
107
|
+
signal: controller.signal,
|
|
108
|
+
});
|
|
109
|
+
if (!response.ok) return null;
|
|
110
|
+
keys = await response.json();
|
|
111
|
+
if (!Array.isArray(keys?.keys)) return null;
|
|
112
|
+
jwksCache.set(jwksUrl, { ...keys, expiresAt: Date.now() + JWKS_TTL_MS });
|
|
113
|
+
return jwksCache.get(jwksUrl);
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
} finally {
|
|
117
|
+
clearTimeout(timeout);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function verifyAccessSignature(
|
|
122
|
+
{ encodedHeader, encodedPayload, encodedSignature, header },
|
|
123
|
+
keys
|
|
124
|
+
) {
|
|
125
|
+
const jwk = keys?.keys?.find((key) => key.kid === header.kid && key.kty === "RSA");
|
|
126
|
+
if (!jwk) return false;
|
|
127
|
+
try {
|
|
128
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
129
|
+
"jwk",
|
|
130
|
+
jwk,
|
|
131
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
132
|
+
false,
|
|
133
|
+
["verify"]
|
|
134
|
+
);
|
|
135
|
+
return await crypto.subtle.verify(
|
|
136
|
+
"RSASSA-PKCS1-v1_5",
|
|
137
|
+
cryptoKey,
|
|
138
|
+
decodeBase64Url(encodedSignature),
|
|
139
|
+
textEncoder.encode(`${encodedHeader}.${encodedPayload}`)
|
|
140
|
+
);
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function verifyAccessJwt(token, env, { fetchImpl = globalThis.fetch } = {}) {
|
|
147
|
+
const decoded = decodeAccessToken(token, env);
|
|
148
|
+
if (!decoded || !hasValidAccessClaims(decoded)) return false;
|
|
149
|
+
const keys = await loadJwks(decoded.jwksUrl, fetchImpl);
|
|
150
|
+
if (!keys) return false;
|
|
151
|
+
return verifyAccessSignature(decoded, keys);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function hmac(secret, value, usage) {
|
|
155
|
+
const key = await crypto.subtle.importKey(
|
|
156
|
+
"raw",
|
|
157
|
+
textEncoder.encode(secret),
|
|
158
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
159
|
+
false,
|
|
160
|
+
usage
|
|
161
|
+
);
|
|
162
|
+
return crypto.subtle.sign("HMAC", key, textEncoder.encode(value));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function base64Url(bytes) {
|
|
166
|
+
let binary = "";
|
|
167
|
+
for (const byte of new Uint8Array(bytes)) binary += String.fromCodePoint(byte);
|
|
168
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function originFromRequest(request) {
|
|
172
|
+
return new URL(request.url).origin;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function createCsrfToken(request, env) {
|
|
176
|
+
const secret = env?.FLAGSHIP_CSRF_SECRET;
|
|
177
|
+
const email = request.headers.get("Cf-Access-Authenticated-User-Email") || "";
|
|
178
|
+
if (!secret || !email) return null;
|
|
179
|
+
return base64Url(await hmac(secret, `${originFromRequest(request)}\n${email}`, ["sign"]));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function verifyCsrfToken(request, env, token) {
|
|
183
|
+
const secret = env?.FLAGSHIP_CSRF_SECRET;
|
|
184
|
+
const email = request.headers.get("Cf-Access-Authenticated-User-Email") || "";
|
|
185
|
+
if (!secret || !email || typeof token !== "string") return false;
|
|
186
|
+
try {
|
|
187
|
+
const key = await crypto.subtle.importKey(
|
|
188
|
+
"raw",
|
|
189
|
+
textEncoder.encode(secret),
|
|
190
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
191
|
+
false,
|
|
192
|
+
["verify"]
|
|
193
|
+
);
|
|
194
|
+
return await crypto.subtle.verify(
|
|
195
|
+
"HMAC",
|
|
196
|
+
key,
|
|
197
|
+
decodeBase64Url(token),
|
|
198
|
+
textEncoder.encode(`${originFromRequest(request)}\n${email}`)
|
|
199
|
+
);
|
|
200
|
+
} catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function authorizeSettingsRequest(
|
|
206
|
+
request,
|
|
207
|
+
env,
|
|
208
|
+
{ verifyToken = verifyAccessJwt, mutation = false, allowedOrigins = [] } = {}
|
|
209
|
+
) {
|
|
210
|
+
if (!hasPassedAccess(request, env)) return { ok: false, status: 403, reason: "access_denied" };
|
|
211
|
+
let validJwt = false;
|
|
212
|
+
try {
|
|
213
|
+
validJwt = await verifyToken(request.headers.get("Cf-Access-Jwt-Assertion"), env);
|
|
214
|
+
} catch {
|
|
215
|
+
validJwt = false;
|
|
216
|
+
}
|
|
217
|
+
if (!validJwt) return { ok: false, status: 403, reason: "access_denied" };
|
|
218
|
+
|
|
219
|
+
if (!mutation) return { ok: true };
|
|
220
|
+
const origin = request.headers.get("origin");
|
|
221
|
+
const referer = request.headers.get("referer");
|
|
222
|
+
const expectedOrigin = originFromRequest(request);
|
|
223
|
+
let suppliedOrigin = origin || null;
|
|
224
|
+
if (!suppliedOrigin && referer) {
|
|
225
|
+
try {
|
|
226
|
+
suppliedOrigin = new URL(referer).origin;
|
|
227
|
+
} catch {
|
|
228
|
+
suppliedOrigin = null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const trustedOrigins = new Set([
|
|
232
|
+
expectedOrigin,
|
|
233
|
+
...(Array.isArray(allowedOrigins) ? allowedOrigins : []),
|
|
234
|
+
]);
|
|
235
|
+
if (!suppliedOrigin || !trustedOrigins.has(suppliedOrigin)) {
|
|
236
|
+
return { ok: false, status: 403, reason: "origin_denied" };
|
|
237
|
+
}
|
|
238
|
+
if (!(request.headers.get("content-type") || "").toLowerCase().includes("application/json")) {
|
|
239
|
+
return { ok: false, status: 415, reason: "content_type_required" };
|
|
240
|
+
}
|
|
241
|
+
if (!(await verifyCsrfToken(request, env, request.headers.get("x-csrf-token")))) {
|
|
242
|
+
return { ok: false, status: 403, reason: "csrf_denied" };
|
|
243
|
+
}
|
|
244
|
+
return { ok: true };
|
|
245
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const ROOT_ASSET_PATHS = new Set([
|
|
2
|
+
"/favicon.ico",
|
|
3
|
+
"/favicon.svg",
|
|
4
|
+
"/index.html",
|
|
5
|
+
"/manifest.webmanifest",
|
|
6
|
+
"/robots.txt",
|
|
7
|
+
]);
|
|
8
|
+
const ASSET_PREFIX = "/assets/";
|
|
9
|
+
|
|
10
|
+
function unavailableResponse() {
|
|
11
|
+
return new Response("Frontend assets are unavailable", {
|
|
12
|
+
status: 503,
|
|
13
|
+
headers: { "content-type": "text/plain;charset=UTF-8" },
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function hasUnsafeAssetSegment(pathname) {
|
|
18
|
+
let decoded = pathname;
|
|
19
|
+
try {
|
|
20
|
+
for (let index = 0; index < 2; index += 1) {
|
|
21
|
+
const next = decodeURIComponent(decoded);
|
|
22
|
+
if (next === decoded) break;
|
|
23
|
+
decoded = next;
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (decoded.includes("%") || decoded.includes("\0") || decoded.includes("\\")) return true;
|
|
30
|
+
return decoded
|
|
31
|
+
.split("/")
|
|
32
|
+
.slice(2)
|
|
33
|
+
.some((segment) => !segment || segment === "." || segment === ".." || segment.startsWith("."));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isFrontendAssetPath(pathname) {
|
|
37
|
+
const candidate = String(pathname || "");
|
|
38
|
+
if (ROOT_ASSET_PATHS.has(candidate)) return true;
|
|
39
|
+
if (!candidate.startsWith(ASSET_PREFIX) || candidate === ASSET_PREFIX) return false;
|
|
40
|
+
return !hasUnsafeAssetSegment(candidate);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function requestForAsset(request, pathname) {
|
|
44
|
+
const url = new URL(request.url);
|
|
45
|
+
url.pathname = pathname;
|
|
46
|
+
return new Request(url, request);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Fetch a known built frontend asset without exposing request paths to a filesystem API. */
|
|
50
|
+
export async function fetchFrontendAsset(env, request, pathname = "/") {
|
|
51
|
+
if (typeof env?.ASSETS?.fetch !== "function") return unavailableResponse();
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
return await env.ASSETS.fetch(requestForAsset(request, pathname));
|
|
55
|
+
} catch {
|
|
56
|
+
return unavailableResponse();
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/lib/endpoints.js
CHANGED
|
@@ -1,12 +1,37 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
function httpUrl(value) {
|
|
2
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
3
|
+
|
|
4
|
+
try {
|
|
5
|
+
const url = new URL(value);
|
|
6
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
return url.href;
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function pageUrl(path, base) {
|
|
16
|
+
if (!base) return null;
|
|
17
|
+
try {
|
|
18
|
+
return new URL(path, base).href;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Build optional legacy hosted-page URLs from a frontend config object. */
|
|
25
|
+
export function endpointsFromFrontend(frontend = {}) {
|
|
26
|
+
const shortenPage = httpUrl(frontend.url);
|
|
27
|
+
const pagesBase = httpUrl(frontend.pagesBase) || shortenPage;
|
|
4
28
|
return {
|
|
5
|
-
shortenPage
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
29
|
+
shortenPage,
|
|
30
|
+
frontendOrigin: shortenPage ? new URL(shortenPage).origin : null,
|
|
31
|
+
notFoundPage: pageUrl("404.html", pagesBase),
|
|
32
|
+
errorPage: pageUrl("error.html", pagesBase),
|
|
33
|
+
safeBrowsingWarning: pageUrl("safe-browsing-warning.html", pagesBase),
|
|
34
|
+
noRefPage: pageUrl("no-ref-page.html", pagesBase),
|
|
10
35
|
safeBrowsing: "https://safebrowsing.googleapis.com/v4/threatMatches:find",
|
|
11
36
|
};
|
|
12
37
|
}
|