@dytsou/shorten-url 2.3.1 → 2.4.2

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/docs/index.html CHANGED
@@ -29,13 +29,9 @@
29
29
 
30
30
  h1 {
31
31
  text-align: center;
32
- color: #333;
32
+ color: #3730a3;
33
33
  margin-bottom: 30px;
34
34
  font-size: 2.5em;
35
- background: linear-gradient(45deg, #667eea, #764ba2);
36
- -webkit-background-clip: text;
37
- -webkit-text-fill-color: transparent;
38
- background-clip: text;
39
35
  }
40
36
 
41
37
  .form-group {
@@ -100,7 +96,7 @@
100
96
  button {
101
97
  width: 100%;
102
98
  padding: 15px;
103
- background: linear-gradient(45deg, #667eeac3, #764ba2ba);
99
+ background: linear-gradient(45deg, #4338ca, #6b21a8);
104
100
  color: white;
105
101
  border: none;
106
102
  border-radius: 10px;
@@ -169,7 +165,7 @@
169
165
 
170
166
  .copy-btn {
171
167
  padding: 10px 20px;
172
- background: #28a745;
168
+ background: #157347;
173
169
  color: white;
174
170
  border: none;
175
171
  border-radius: 5px;
@@ -182,7 +178,7 @@
182
178
  }
183
179
 
184
180
  .copy-btn:hover {
185
- background: #218838;
181
+ background: #0f5132;
186
182
  transform: translateY(-1px);
187
183
  }
188
184
 
@@ -215,7 +211,7 @@
215
211
  }
216
212
 
217
213
  .error {
218
- color: #dc3545;
214
+ color: #842029;
219
215
  background: #f8d7da;
220
216
  border: 1px solid #f5c6cb;
221
217
  padding: 10px;
@@ -244,6 +240,18 @@
244
240
  margin-top: 5px;
245
241
  }
246
242
 
243
+ .sr-only {
244
+ position: absolute;
245
+ width: 1px;
246
+ height: 1px;
247
+ padding: 0;
248
+ margin: -1px;
249
+ overflow: hidden;
250
+ clip: rect(0, 0, 0, 0);
251
+ white-space: nowrap;
252
+ border: 0;
253
+ }
254
+
247
255
  @media (max-width: 768px) {
248
256
  .container {
249
257
  margin: 20px auto;
@@ -353,6 +361,7 @@
353
361
  <div class="result" id="result">
354
362
  <h3>Your shortened URL:</h3>
355
363
  <div class="shortened-url">
364
+ <label for="shortUrlOutput" class="sr-only">Shortened URL</label>
356
365
  <input type="text" id="shortUrlOutput" readonly />
357
366
  <button class="copy-btn" onclick="copyToClipboard()">Copy</button>
358
367
  </div>
@@ -559,6 +568,7 @@
559
568
  document.getElementById("error").classList.add("show");
560
569
  }
561
570
  } catch (error) {
571
+ console.error("Network error:", error);
562
572
  // Hide loading
563
573
  document.getElementById("loading").classList.remove("show");
564
574
 
@@ -573,10 +583,10 @@
573
583
  const shortUrlInput = document.getElementById("shortUrlOutput");
574
584
  const copyBtn = document.querySelector(".copy-btn");
575
585
  const originalText = copyBtn.textContent;
576
- const originalBg = copyBtn.style.backgroundColor || "#28a745";
586
+ const originalBg = copyBtn.style.backgroundColor || "#157347";
577
587
 
578
588
  if (!shortUrlInput.value) {
579
- showCopyFeedback(copyBtn, "No URL!", "#dc3545", originalText, originalBg);
589
+ showButtonFeedback(copyBtn, "No URL!", "#dc3545", originalText, originalBg);
580
590
  return;
581
591
  }
582
592
 
@@ -584,12 +594,12 @@
584
594
  // Try modern Clipboard API first
585
595
  if (navigator.clipboard && window.isSecureContext) {
586
596
  await navigator.clipboard.writeText(shortUrlInput.value);
587
- showCopyFeedback(copyBtn, "Copied!", "#28a745", originalText, originalBg);
597
+ showButtonFeedback(copyBtn, "Copied!", "#28a745", originalText, originalBg);
588
598
  } else {
589
599
  // Fallback: ask user to copy manually
590
600
  shortUrlInput.select();
591
601
  shortUrlInput.focus();
592
- showCopyFeedback(copyBtn, "Select & Copy", "#ffc107", originalText, originalBg, 3000);
602
+ showButtonFeedback(copyBtn, "Select & Copy", "#ffc107", originalText, originalBg, 3000);
593
603
  }
594
604
  } catch (err) {
595
605
  console.error("Failed to copy: ", err);
@@ -597,12 +607,19 @@
597
607
  // Final fallback - show the URL and ask user to copy manually
598
608
  shortUrlInput.select();
599
609
  shortUrlInput.focus();
600
- showCopyFeedback(copyBtn, "Select & Copy", "#ffc107", originalText, originalBg, 3000);
610
+ showButtonFeedback(copyBtn, "Select & Copy", "#ffc107", originalText, originalBg, 3000);
601
611
  }
602
612
  }
603
613
 
604
- // Helper function for copy feedback
605
- function showCopyFeedback(button, text, bgColor, originalText, originalBg, duration = 2000) {
614
+ // Helper function for button feedback
615
+ function showButtonFeedback(
616
+ button,
617
+ text,
618
+ bgColor,
619
+ originalText,
620
+ originalBg,
621
+ duration = 2000
622
+ ) {
606
623
  button.textContent = text;
607
624
  button.style.backgroundColor = bgColor;
608
625
  button.style.transform = "scale(0.95)";
@@ -624,12 +641,12 @@
624
641
 
625
642
  // Validate URL input
626
643
  if (!longUrl) {
627
- showGotoFeedback(gotoBtn, "Enter URL first!", "#dc3545", originalText, originalBg);
644
+ showButtonFeedback(gotoBtn, "Enter URL first!", "#dc3545", originalText, originalBg);
628
645
  return;
629
646
  }
630
647
 
631
648
  // Show processing state
632
- showGotoFeedback(gotoBtn, "Shortening...", "#ffc107", originalText, originalBg, 10000);
649
+ showButtonFeedback(gotoBtn, "Shortening...", "#ffc107", originalText, originalBg, 10000);
633
650
 
634
651
  try {
635
652
  // Prepare the request payload
@@ -661,7 +678,7 @@
661
678
  window.open(shortUrl, "_blank", "noopener,noreferrer");
662
679
 
663
680
  // Show success feedback
664
- showGotoFeedback(gotoBtn, "Opened!", "#28a745", originalText, originalBg);
681
+ showButtonFeedback(gotoBtn, "Opened!", "#28a745", originalText, originalBg);
665
682
 
666
683
  // Reset form
667
684
  document.getElementById("urlForm").reset();
@@ -687,31 +704,18 @@
687
704
  response.status,
688
705
  "Unable to shorten URL. Please verify the URL and try again."
689
706
  );
690
- showGotoFeedback(gotoBtn, "Error!", "#dc3545", originalText, originalBg);
707
+ showButtonFeedback(gotoBtn, "Error!", "#dc3545", originalText, originalBg);
691
708
  document.getElementById("error").textContent = errorMsg;
692
709
  document.getElementById("error").classList.add("show");
693
710
  }
694
711
  } catch (error) {
695
712
  console.error("Network error:", error);
696
- showGotoFeedback(gotoBtn, "Network Error!", "#dc3545", originalText, originalBg);
713
+ showButtonFeedback(gotoBtn, "Network Error!", "#dc3545", originalText, originalBg);
697
714
  document.getElementById("error").textContent = "Network error. Please try again.";
698
715
  document.getElementById("error").classList.add("show");
699
716
  }
700
717
  }
701
718
 
702
- // Helper function for goto feedback
703
- function showGotoFeedback(button, text, bgColor, originalText, originalBg, duration = 2000) {
704
- button.textContent = text;
705
- button.style.backgroundColor = bgColor;
706
- button.style.transform = "scale(0.95)";
707
-
708
- setTimeout(() => {
709
- button.textContent = originalText;
710
- button.style.backgroundColor = originalBg;
711
- button.style.transform = "scale(1)";
712
- }, duration);
713
- }
714
-
715
719
  // Custom slug validation
716
720
  document.getElementById("customSlug").addEventListener("input", function (e) {
717
721
  const value = e.target.value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dytsou/shorten-url",
3
- "version": "2.3.1",
3
+ "version": "2.4.2",
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",
@@ -22,16 +22,18 @@
22
22
  },
23
23
  "homepage": "https://github.com/dytsou/shorten-url#readme",
24
24
  "engines": {
25
- "node": ">=18.0.0"
25
+ "node": ">=24.0.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "prettier": "^3.7.4",
29
- "vitest": "^4.0.16",
30
- "wrangler": "^4.58.0"
29
+ "vite": "7.3.6",
30
+ "vitest": "^4.1.10",
31
+ "wrangler": "^4.120.0"
31
32
  },
32
33
  "files": [
33
34
  "src/worker.js",
34
35
  "src/worker.example.js",
36
+ "src/lib/",
35
37
  "config/",
36
38
  "docs/",
37
39
  "README.md",
@@ -40,6 +42,7 @@
40
42
  "scripts": {
41
43
  "dev": "wrangler dev",
42
44
  "deploy": "wrangler deploy",
45
+ "logs": "wrangler tail",
43
46
  "preview": "wrangler dev",
44
47
  "publish:npmjs": "pnpm publish --registry https://registry.npmjs.org",
45
48
  "publish:github": "pnpm publish --registry https://npm.pkg.github.com",
@@ -0,0 +1,5 @@
1
+ export function hasPassedAccess(request) {
2
+ const jwt = request.headers.get("Cf-Access-Jwt-Assertion");
3
+ const email = request.headers.get("Cf-Access-Authenticated-User-Email");
4
+ return Boolean(jwt && email);
5
+ }
@@ -0,0 +1,2 @@
1
+ export const SLUG_PATTERN = /^[a-zA-Z0-9\-_]+$/;
2
+ export const URL_PATTERN = /http(s)?:\/\/([\w-]+\.)+[\w-]+(\/[\w- ./?%&=]*)?/;
@@ -0,0 +1,18 @@
1
+ export function randomString(worker, len = worker.min_random_key_length) {
2
+ const chars = worker.random_chars;
3
+ const bytes = new Uint8Array(len);
4
+ crypto.getRandomValues(bytes);
5
+ let result = "";
6
+ for (let i = 0; i < len; i++) {
7
+ result += chars.charAt(bytes[i] % chars.length);
8
+ }
9
+ return result;
10
+ }
11
+
12
+ export async function sha512(text) {
13
+ const data = new TextEncoder().encode(text);
14
+ const digest = await crypto.subtle.digest("SHA-512", data);
15
+ return Array.from(new Uint8Array(digest))
16
+ .map((b) => b.toString(16).padStart(2, "0"))
17
+ .join("");
18
+ }
@@ -0,0 +1,12 @@
1
+ /** Build hosted page URLs from a frontend config object. */
2
+ export function endpointsFromFrontend(frontend) {
3
+ const pagesBase = frontend.pagesBase || frontend.url;
4
+ return {
5
+ shortenPage: frontend.url,
6
+ notFoundPage: new URL("404.html", pagesBase).href,
7
+ errorPage: new URL("error.html", pagesBase).href,
8
+ safeBrowsingWarning: new URL("safe-browsing-warning.html", pagesBase).href,
9
+ noRefPage: new URL("no-ref-page.html", pagesBase).href,
10
+ safeBrowsing: "https://safebrowsing.googleapis.com/v4/threatMatches:find",
11
+ };
12
+ }
package/src/lib/kv.js ADDED
@@ -0,0 +1,49 @@
1
+ import { randomString, sha512 } from "./crypto.js";
2
+ import { logError, traceSpan } from "./observability.js";
3
+
4
+ export function createKvStore({ worker, kv }) {
5
+ async function saveUrl(url, customSlug = null) {
6
+ return traceSpan("kv.save", async (span) => {
7
+ if (span.isTraced) span.setAttribute("kv.custom_slug", Boolean(customSlug));
8
+ try {
9
+ const key = customSlug || randomString(worker);
10
+ const existing = await kv.get(key);
11
+
12
+ if (existing === null) {
13
+ await kv.put(key, url);
14
+ return [undefined, key];
15
+ }
16
+ if (customSlug) {
17
+ return ["CUSTOM_SLUG_EXISTS", null];
18
+ }
19
+ return saveUrl(url);
20
+ } catch (error) {
21
+ logError("kv.save_failed", error);
22
+ return ["KV_ERROR", null];
23
+ }
24
+ });
25
+ }
26
+
27
+ async function findUrlKeyByHash(urlHash) {
28
+ return (await kv.get(urlHash)) || null;
29
+ }
30
+
31
+ async function resolveShortKey(longUrl, customSlug) {
32
+ return traceSpan("kv.resolve", async (span) => {
33
+ if (span.isTraced) span.setAttribute("kv.unique_link", worker.unique_link);
34
+ if (worker.unique_link && !customSlug) {
35
+ const urlHash = await sha512(longUrl);
36
+ const existingKey = await findUrlKeyByHash(urlHash);
37
+ if (existingKey) {
38
+ return [undefined, existingKey];
39
+ }
40
+ const [errorCode, key] = await saveUrl(longUrl);
41
+ if (errorCode === undefined) await kv.put(urlHash, key);
42
+ return [errorCode, key];
43
+ }
44
+ return saveUrl(longUrl, customSlug);
45
+ });
46
+ }
47
+
48
+ return { saveUrl, findUrlKeyByHash, resolveShortKey };
49
+ }
@@ -0,0 +1,86 @@
1
+ import { tracing } from "cloudflare:workers";
2
+
3
+ /** Structured JSON log for Workers Logs indexing. */
4
+ export function log(level, event, fields = {}) {
5
+ console.log({ level, event, ts: new Date().toISOString(), ...fields });
6
+ }
7
+
8
+ export function logError(event, error, fields = {}) {
9
+ console.error({
10
+ level: "error",
11
+ event,
12
+ ts: new Date().toISOString(),
13
+ error:
14
+ error instanceof Error
15
+ ? { message: error.message, name: error.name }
16
+ : { message: String(error) },
17
+ ...fields,
18
+ });
19
+ }
20
+
21
+ export function requestFields(request) {
22
+ const url = new URL(request.url);
23
+ return {
24
+ http: {
25
+ method: request.method,
26
+ path: url.pathname,
27
+ ...(url.search ? { query: url.search } : {}),
28
+ },
29
+ ...(request.cf?.colo || request.cf?.country
30
+ ? {
31
+ client: {
32
+ ...(request.cf.colo ? { colo: request.cf.colo } : {}),
33
+ ...(request.cf.country ? { country: request.cf.country } : {}),
34
+ },
35
+ }
36
+ : {}),
37
+ };
38
+ }
39
+
40
+ function setRequestSpanAttributes(span, request) {
41
+ if (!span.isTraced) return;
42
+ const url = new URL(request.url);
43
+ span.setAttribute("http.request.method", request.method);
44
+ span.setAttribute("url.path", url.pathname);
45
+ if (request.cf?.colo) span.setAttribute("cf.colo", request.cf.colo);
46
+ if (request.cf?.country) span.setAttribute("cf.country", request.cf.country);
47
+ }
48
+
49
+ /** Wrap a Worker fetch handler with request-level spans and structured logs. */
50
+ export function withFetchObservability(handler) {
51
+ async function observedFetch(request, env, ctx) {
52
+ return tracing.enterSpan("fetch", async (span) => {
53
+ const fields = requestFields(request);
54
+ setRequestSpanAttributes(span, request);
55
+ log("info", "request.start", fields);
56
+
57
+ try {
58
+ const response = await handler(request, env, ctx);
59
+ const status = response.status;
60
+ if (span.isTraced) span.setAttribute("http.response.status_code", status);
61
+ log("info", "request.complete", {
62
+ ...fields,
63
+ http: { ...fields.http, status },
64
+ });
65
+ return response;
66
+ } catch (error) {
67
+ logError("request.error", error, fields);
68
+ throw error;
69
+ }
70
+ });
71
+ }
72
+
73
+ return { fetch: observedFetch };
74
+ }
75
+
76
+ /** Run async work inside a named custom span. */
77
+ export function traceSpan(name, fn, attributes = {}) {
78
+ return tracing.enterSpan(name, async (span) => {
79
+ if (span.isTraced) {
80
+ for (const [key, value] of Object.entries(attributes)) {
81
+ if (value !== undefined) span.setAttribute(key, value);
82
+ }
83
+ }
84
+ return fn(span);
85
+ });
86
+ }
@@ -0,0 +1,63 @@
1
+ import { wantsJson } from "./validate.js";
2
+
3
+ async function fetchHostedPage(url, status = 200) {
4
+ const upstream = await fetch(url, { redirect: "follow" });
5
+ return new Response(await upstream.text(), {
6
+ status,
7
+ headers: { "content-type": "text/html;charset=UTF-8" },
8
+ });
9
+ }
10
+
11
+ async function fetchInterstitial(url, destination) {
12
+ const upstream = await fetch(url);
13
+ const html = (await upstream.text()).replace(/{Replace}/gm, destination);
14
+ return new Response(html, { headers: { "content-type": "text/html;charset=UTF-8" } });
15
+ }
16
+
17
+ export function createResponses({ worker, endpoints }) {
18
+ function corsHeaders() {
19
+ if (worker.cors !== "on") return {};
20
+ return {
21
+ "Access-Control-Allow-Origin": "*",
22
+ "Access-Control-Allow-Methods": "POST, GET, OPTIONS",
23
+ };
24
+ }
25
+
26
+ function htmlHeaders() {
27
+ return { "content-type": "text/html;charset=UTF-8", ...corsHeaders() };
28
+ }
29
+
30
+ function jsonHeaders() {
31
+ return { "content-type": "application/json;charset=UTF-8", ...corsHeaders() };
32
+ }
33
+
34
+ function jsonResponse(payload, status) {
35
+ return new Response(JSON.stringify(payload), { status, headers: jsonHeaders() });
36
+ }
37
+
38
+ async function errorResponse(message, code, request) {
39
+ if (wantsJson(request) || !endpoints.errorPage) {
40
+ return jsonResponse({ status: code, message }, code);
41
+ }
42
+ const url = `${endpoints.errorPage}?message=${encodeURIComponent(message)}&code=${encodeURIComponent(code)}`;
43
+ return fetchHostedPage(url, code);
44
+ }
45
+
46
+ function notFound() {
47
+ if (!endpoints.notFoundPage) {
48
+ return jsonResponse({ status: 404, message: "URL not found" }, 404);
49
+ }
50
+ return fetchHostedPage(endpoints.notFoundPage, 404);
51
+ }
52
+
53
+ return {
54
+ corsHeaders,
55
+ htmlHeaders,
56
+ jsonHeaders,
57
+ jsonResponse,
58
+ fetchHostedPage,
59
+ fetchInterstitial,
60
+ errorResponse,
61
+ notFound,
62
+ };
63
+ }
@@ -0,0 +1,28 @@
1
+ import { traceSpan } from "./observability.js";
2
+
3
+ export async function isUrlSafe(url, worker, endpoints) {
4
+ return traceSpan("safe_browsing.check", async () => {
5
+ const body = JSON.stringify({
6
+ client: { clientId: "Url-Shorten-Worker", clientVersion: "1.0.7" },
7
+ threatInfo: {
8
+ threatTypes: [
9
+ "MALWARE",
10
+ "SOCIAL_ENGINEERING",
11
+ "POTENTIALLY_HARMFUL_APPLICATION",
12
+ "UNWANTED_SOFTWARE",
13
+ ],
14
+ platformTypes: ["ANY_PLATFORM"],
15
+ threatEntryTypes: ["URL"],
16
+ threatEntries: [{ url }],
17
+ },
18
+ });
19
+
20
+ const response = await fetch(`${endpoints.safeBrowsing}?key=${worker.safe_browsing_api_key}`, {
21
+ method: "POST",
22
+ body,
23
+ redirect: "follow",
24
+ });
25
+ const result = await response.json();
26
+ return Object.keys(result).length === 0;
27
+ });
28
+ }
@@ -0,0 +1,119 @@
1
+ import { hasPassedAccess } from "./access.js";
2
+ import { createKvStore } from "./kv.js";
3
+ import { log, logError, traceSpan } from "./observability.js";
4
+ import { createResponses } from "./responses.js";
5
+ import { isUrlSafe } from "./safety.js";
6
+ import { isValidCustomSlug, isValidUrl } from "./validate.js";
7
+
8
+ export function createShortener({ worker, endpoints, kv }) {
9
+ const responses = createResponses({ worker, endpoints });
10
+ const kvStore = createKvStore({ worker, kv });
11
+ const reservedSlugSet = new Set(worker.reserved_slugs.map((s) => s.toLowerCase()));
12
+ const { jsonResponse, fetchInterstitial, errorResponse, notFound } = responses;
13
+ const { resolveShortKey } = kvStore;
14
+
15
+ function respondToSaveError(errorCode, request) {
16
+ if (errorCode === "CUSTOM_SLUG_EXISTS") {
17
+ return errorResponse("Custom slug already exists", 400, request);
18
+ }
19
+ if (errorCode === "KV_ERROR") {
20
+ return errorResponse("Database error occurred", 500, request);
21
+ }
22
+ return errorResponse("Error: Reach the KV write limitation", 500, request);
23
+ }
24
+
25
+ async function validateShortenPayload(body, request) {
26
+ const longUrl = body.url;
27
+ if (!longUrl) return { error: await errorResponse("URL is required", 400, request) };
28
+ if (!isValidUrl(longUrl)) {
29
+ return { error: await errorResponse("Invalid URL format", 400, request) };
30
+ }
31
+
32
+ const customSlug = body.custom_slug || null;
33
+ if (!customSlug) return { longUrl, customSlug: null };
34
+
35
+ if (!worker.custom_link) {
36
+ return { error: await errorResponse("Custom URLs are disabled", 400, request) };
37
+ }
38
+ if (!isValidCustomSlug(customSlug, worker, reservedSlugSet)) {
39
+ return { error: await errorResponse("Invalid custom slug format", 400, request) };
40
+ }
41
+ return { longUrl, customSlug };
42
+ }
43
+
44
+ async function handleShorten(
45
+ request,
46
+ requestURL,
47
+ { apiPath = "/", allowedApiPaths = ["/"], requireAccess = true } = {}
48
+ ) {
49
+ return traceSpan("shorten", async (span) => {
50
+ if (span.isTraced) span.setAttribute("api.path", apiPath);
51
+
52
+ const allowedPaths =
53
+ allowedApiPaths instanceof Set ? allowedApiPaths : new Set(allowedApiPaths);
54
+ if (!allowedPaths.has(apiPath)) {
55
+ return jsonResponse({ status: 404, message: "Invalid API path" }, 404);
56
+ }
57
+
58
+ if (requireAccess && !hasPassedAccess(request)) {
59
+ log("warn", "shorten.access_denied", { api: { path: apiPath } });
60
+ return errorResponse("You must use WARP to shorten the URL", 403, request);
61
+ }
62
+
63
+ let body;
64
+ try {
65
+ body = await request.json();
66
+ } catch (error) {
67
+ logError("shorten.invalid_json", error, { api: { path: apiPath } });
68
+ return errorResponse("Invalid JSON format", 400, request);
69
+ }
70
+
71
+ const validated = await validateShortenPayload(body, request);
72
+ if (validated.error) return validated.error;
73
+
74
+ const [errorCode, key] = await resolveShortKey(validated.longUrl, validated.customSlug);
75
+ if (errorCode === undefined) {
76
+ log("info", "shorten.created", {
77
+ key,
78
+ custom_slug: Boolean(validated.customSlug),
79
+ });
80
+ return jsonResponse({ short_url: `${requestURL.origin}/${key}` }, 201);
81
+ }
82
+ log("warn", "shorten.save_failed", { error_code: errorCode });
83
+ return respondToSaveError(errorCode, request);
84
+ });
85
+ }
86
+
87
+ async function handleShortUrlRedirect(path, params, suffix = null) {
88
+ return traceSpan("redirect", async (span) => {
89
+ const key = suffix ? `${path}/${suffix}` : path;
90
+ if (span.isTraced) span.setAttribute("shortlink.key", key || "");
91
+
92
+ if (!key) return notFound();
93
+
94
+ const value = await kv.get(key);
95
+ if (!value) {
96
+ log("info", "redirect.not_found", { key });
97
+ return notFound();
98
+ }
99
+
100
+ const destination = params ? value + params : value;
101
+
102
+ if (worker.safe_browsing_api_key && !(await isUrlSafe(destination, worker, endpoints))) {
103
+ log("warn", "redirect.unsafe_url", { key });
104
+ return fetchInterstitial(endpoints.safeBrowsingWarning, destination);
105
+ }
106
+ if (worker.no_ref === "on") {
107
+ return fetchInterstitial(endpoints.noRefPage, destination);
108
+ }
109
+ log("info", "redirect.success", { key });
110
+ return Response.redirect(destination, 302);
111
+ });
112
+ }
113
+
114
+ return {
115
+ ...responses,
116
+ handleShorten,
117
+ handleShortUrlRedirect,
118
+ };
119
+ }
@@ -0,0 +1,24 @@
1
+ import { SLUG_PATTERN, URL_PATTERN } from "./constants.js";
2
+
3
+ export function isValidUrl(url) {
4
+ return typeof url === "string" && url.startsWith("h") && URL_PATTERN.test(url);
5
+ }
6
+
7
+ export function isValidCustomSlug(slug, worker, reservedSlugs = worker.reserved_slugs) {
8
+ const reserved =
9
+ reservedSlugs instanceof Set
10
+ ? reservedSlugs
11
+ : new Set(reservedSlugs.map((s) => s.toLowerCase()));
12
+ return (
13
+ SLUG_PATTERN.test(slug) &&
14
+ slug.length >= 1 &&
15
+ slug.length <= worker.max_custom_slug_length &&
16
+ !reserved.has(slug.toLowerCase())
17
+ );
18
+ }
19
+
20
+ export function wantsJson(request) {
21
+ const accept = request.headers.get("accept") || "";
22
+ const contentType = request.headers.get("content-type") || "";
23
+ return accept.includes("application/json") || contentType.includes("application/json");
24
+ }
@@ -1,376 +1,65 @@
1
+ /**
2
+ * URL Shortener — Cloudflare Worker (template)
3
+ *
4
+ * Copy this file to `src/worker.js` and wire in your config (see README).
5
+ * Requires a KV namespace bound as `LINKS`.
6
+ *
7
+ * Routes:
8
+ * OPTIONS * -> CORS preflight
9
+ * POST / -> create short URL (protected by Cloudflare Access / WARP)
10
+ * GET / -> serve the shortener frontend
11
+ * GET /<key> -> redirect a stored short link
12
+ */
13
+
14
+ import { createShortener } from "./lib/shortener.js";
15
+ import { endpointsFromFrontend } from "./lib/endpoints.js";
16
+ import { withFetchObservability } from "./lib/observability.js";
17
+
18
+ // ============================================================================
19
+ // Configuration
20
+ // ============================================================================
21
+
1
22
  let config;
2
23
 
3
24
  try {
4
- // In a real deployment, you might want to fetch this from KV storage
5
- // or use environment variables for sensitive data
6
25
  if (typeof importConfig !== "undefined") {
7
26
  config = { ...defaultConfig, ...importConfig };
8
- }
9
- } catch (e) {
10
- console.log("Error importing config");
11
- throw e;
12
- exit();
13
- }
14
-
15
- let response_header = {
16
- "content-type": "text/html;charset=UTF-8",
17
- };
18
-
19
- if (config.worker.cors == "on") {
20
- response_header = {
21
- "content-type": "text/html;charset=UTF-8",
22
- "Access-Control-Allow-Origin": "*",
23
- "Access-Control-Allow-Methods": "POST",
24
- };
25
- }
26
-
27
- // Check whether this request has passed Cloudflare Access (Zero Trust / WARP)
28
- function requireAccess(request) {
29
- const jwt = request.headers.get("Cf-Access-Jwt-Assertion");
30
- const email = request.headers.get("Cf-Access-Authenticated-User-Email");
31
-
32
- console.log("Access headers:", {
33
- jwtPresent: !!jwt,
34
- email,
35
- });
36
-
37
- // Defensive check: if Access is misconfigured, block unauthenticated traffic
38
- if (!jwt || !email) {
39
- return false;
40
- }
41
-
42
- // If you want to restrict to specific accounts, you can check email here:
43
- // const allowed = ["you@nycu.edu.tw", "xxx@example.com"];
44
- // if (!allowed.includes(email)) return false;
45
-
46
- return true;
47
- }
48
-
49
- // Create JSON error response
50
- function createJsonErrorResponse(message, code) {
51
- return new Response(JSON.stringify({ status: code, message: message }), {
52
- status: code,
53
- headers: {
54
- ...response_header,
55
- "content-type": "application/json;charset=UTF-8",
56
- },
57
- });
58
- }
59
-
60
- // Generate random string for short URLs
61
- async function randomString(len) {
62
- len = len || config.worker.min_random_key_length;
63
- let $chars = config.worker.random_chars; // Configurable character set
64
- let maxPos = $chars.length;
65
- let result = "";
66
- for (i = 0; i < len; i++) {
67
- result += $chars.charAt(Math.floor(Math.random() * maxPos));
68
- }
69
- return result;
70
- }
71
-
72
- // Generate SHA512 hash for URL
73
- async function sha512(url) {
74
- url = new TextEncoder().encode(url);
75
-
76
- const url_digest = await crypto.subtle.digest(
77
- {
78
- name: "SHA-512",
79
- },
80
- url // The data you want to hash as an ArrayBuffer
81
- );
82
- const hashArray = Array.from(new Uint8Array(url_digest)); // convert buffer to byte array
83
- const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
84
- return hashHex;
85
- }
86
-
87
- // Validate URL format
88
- async function checkURL(URL) {
89
- let str = URL;
90
- let Expression = /http(s)?:\/\/([\w-]+\.)+[\w-]+(\/[\w- .\/?%&=]*)?/;
91
- let objExp = new RegExp(Expression);
92
- if (objExp.test(str) == true) {
93
- if (str[0] == "h") return true;
94
- else return false;
95
27
  } else {
96
- return false;
28
+ throw new TypeError(
29
+ "Worker config is missing. Copy config/config.example.js to config/config.js and import it when building worker.js."
30
+ );
97
31
  }
32
+ } catch (error) {
33
+ console.error("Failed to load worker config:", error);
34
+ throw error;
98
35
  }
99
36
 
100
- // Validate custom slug format
101
- async function validateCustomSlug(slug) {
102
- // Check format - only allow letters, numbers, hyphens, and underscores
103
- const slugRegex = /^[a-zA-Z0-9\-_]+$/;
104
- if (!slugRegex.test(slug)) {
105
- return false;
106
- }
107
-
108
- // Check length using config
109
- if (slug.length < 1 || slug.length > config.worker.max_custom_slug_length) {
110
- return false;
111
- }
112
-
113
- // Check reserved words from config
114
- if (config.worker.reserved_slugs.includes(slug.toLowerCase())) {
115
- return false;
116
- }
117
-
118
- return true;
119
- }
120
-
121
- // Save URL with random or custom key
122
- async function save_url(URL, customSlug = null) {
123
- try {
124
- let random_key;
125
-
126
- if (customSlug) {
127
- // Use custom slug if provided
128
- random_key = customSlug;
129
- } else {
130
- // Generate random key
131
- random_key = await randomString();
132
- }
133
-
134
- let is_exist = await LINKS.get(random_key);
135
- console.log(is_exist);
136
-
137
- if (is_exist == null) {
138
- await LINKS.put(random_key, URL);
139
- return [undefined, random_key];
140
- } else if (customSlug) {
141
- // If custom slug already exists, return error
142
- return ["CUSTOM_SLUG_EXISTS", null];
143
- } else {
144
- // If random key exists, try again
145
- return save_url(URL);
146
- }
147
- } catch (error) {
148
- console.error("Error in save_url:", error);
149
- return ["KV_ERROR", null];
150
- }
151
- }
152
-
153
- // Check if URL exists in database
154
- async function is_url_exist(url_sha512) {
155
- let is_exist = await LINKS.get(url_sha512);
156
- console.log(is_exist);
157
- if (is_exist == null) {
158
- return false;
159
- } else {
160
- return is_exist;
161
- }
162
- }
163
-
164
- // Check URL safety using Google Safe Browsing API
165
- async function is_url_safe(url) {
166
- let raw = JSON.stringify({
167
- client: {
168
- clientId: "Url-Shorten-Worker",
169
- clientVersion: "1.0.7",
170
- },
171
- threatInfo: {
172
- threatTypes: [
173
- "MALWARE",
174
- "SOCIAL_ENGINEERING",
175
- "POTENTIALLY_HARMFUL_APPLICATION",
176
- "UNWANTED_SOFTWARE",
177
- ],
178
- platformTypes: ["ANY_PLATFORM"],
179
- threatEntryTypes: ["URL"],
180
- threatEntries: [{ url: url }],
181
- },
182
- });
183
-
184
- let requestOptions = {
185
- method: "POST",
186
- body: raw,
187
- redirect: "follow",
188
- };
189
-
190
- let result = await fetch(
191
- "https://safebrowsing.googleapis.com/v4/threatMatches:find?key=" +
192
- config.worker.safe_browsing_api_key,
193
- requestOptions
194
- );
195
- result = await result.json();
196
- console.log(result);
197
- if (Object.keys(result).length === 0) {
198
- return true;
199
- } else {
200
- return false;
201
- }
37
+ function getEndpoints() {
38
+ return endpointsFromFrontend(config.frontend);
202
39
  }
203
40
 
204
- // Handle short URL redirect
205
- async function handleShortUrlRedirect(path, params) {
206
- if (!path) {
207
- const html404 = await fetch("https://dytsou.github.io/404.html");
208
- return new Response(await html404.text(), {
209
- headers: {
210
- "content-type": "text/html;charset=UTF-8",
211
- },
212
- status: 404,
213
- });
214
- }
215
-
216
- const value = await LINKS.get(path);
217
- let location;
218
-
219
- if (params) {
220
- location = value + params;
221
- } else {
222
- location = value;
223
- }
224
- console.log(value);
225
-
226
- if (location) {
227
- if (config.worker.safe_browsing_api_key) {
228
- if (!(await is_url_safe(location))) {
229
- let warning_page = safeBrowsingWarning.replace(/{Replace}/gm, location);
230
- return new Response(warning_page, {
231
- headers: {
232
- "content-type": "text/html;charset=UTF-8",
233
- },
234
- });
235
- }
236
- }
237
-
238
- if (config.worker.no_ref == "on") {
239
- let no_ref = noRefPage.replace(/{Replace}/gm, location);
240
- return new Response(no_ref, {
241
- headers: {
242
- "content-type": "text/html;charset=UTF-8",
243
- },
244
- });
245
- } else {
246
- return Response.redirect(location, 302);
247
- }
248
- }
249
-
250
- // If request not in kv, return 404
251
- const html404 = await fetch("https://dytsou.github.io/404.html");
252
- return new Response(await html404.text(), {
253
- headers: {
254
- "content-type": "text/html;charset=UTF-8",
255
- },
256
- status: 404,
41
+ async function exampleFetchHandler(request, env) {
42
+ const shortener = createShortener({
43
+ worker: config.worker,
44
+ endpoints: getEndpoints(),
45
+ kv: env.LINKS,
257
46
  });
258
- }
259
-
260
- // Handle GET requests
261
- async function handleGetRequest(requestURL) {
262
- const path = requestURL.pathname.split("/")[1];
263
- const params = requestURL.search;
264
- console.log(path);
265
- return await handleShortUrlRedirect(path, params);
266
- }
267
-
268
- // Handle POST requests (URL shortening)
269
- async function handlePostRequest(request, requestURL, pathname) {
270
- // Only allow shortening via the /shorten endpoint
271
- if (pathname !== "/shorten") {
272
- return createJsonErrorResponse("Invalid API path", 404);
273
- }
274
-
275
- // Enforce Cloudflare Access / WARP for this API
276
- if (!requireAccess(request)) {
277
- return createJsonErrorResponse("You must use WARP to shorten the URL", 403);
278
- }
279
-
280
- let req;
281
- try {
282
- req = await request.json();
283
- console.log(req["url"]);
284
- } catch (error) {
285
- console.error("Error parsing JSON:", error);
286
- return createJsonErrorResponse("Invalid JSON format", 400);
287
- }
288
-
289
- // Validate URL
290
- if (!req["url"]) {
291
- return createJsonErrorResponse("URL is required", 400);
292
- }
293
-
294
- if (!(await checkURL(req["url"]))) {
295
- return createJsonErrorResponse("Invalid URL format", 400);
296
- }
297
-
298
- let stat, random_key;
299
- let customSlug = req["custom_slug"] || null;
300
-
301
- // Validate custom slug if provided
302
- if (customSlug) {
303
- if (!config.worker.custom_link) {
304
- return createJsonErrorResponse("Custom URLs are disabled", 400);
305
- }
306
-
307
- if (!(await validateCustomSlug(customSlug))) {
308
- return createJsonErrorResponse("Invalid custom slug format", 400);
309
- }
310
- }
311
-
312
- if (config.worker.unique_link && !customSlug) {
313
- let url_sha512 = await sha512(req["url"]);
314
- let url_key = await is_url_exist(url_sha512);
315
- if (url_key) {
316
- random_key = url_key;
317
- } else {
318
- [stat, random_key] = await save_url(req["url"], customSlug);
319
- if (typeof stat == "undefined") {
320
- console.log(await LINKS.put(url_sha512, random_key));
321
- }
322
- }
323
- } else {
324
- [stat, random_key] = await save_url(req["url"], customSlug);
325
- }
326
-
327
- console.log(stat);
328
-
329
- if (stat === "CUSTOM_SLUG_EXISTS") {
330
- return createJsonErrorResponse("Custom slug already exists", 400);
331
- }
332
-
333
- if (stat === "KV_ERROR") {
334
- return createJsonErrorResponse("Database error occurred", 500);
335
- }
336
-
337
- if (typeof stat == "undefined") {
338
- const shortUrl = `${requestURL.origin}/${random_key}`;
339
- return new Response(JSON.stringify({ short_url: shortUrl }), {
340
- status: 201,
341
- headers: {
342
- ...response_header,
343
- "content-type": "application/json;charset=UTF-8",
344
- },
345
- });
346
- }
347
-
348
- return createJsonErrorResponse("Error: Reach the KV write limitation", 500);
349
- }
350
-
351
- // Main request handler
352
- async function handleRequest(request) {
353
- console.log(request);
354
47
 
355
48
  const requestURL = new URL(request.url);
356
- const pathname = requestURL.pathname;
49
+ const [, path] = requestURL.pathname.split("/");
50
+ const params = requestURL.search;
357
51
 
358
- // CORS preflight
359
52
  if (request.method === "OPTIONS") {
360
- return new Response("", {
361
- status: 204,
362
- headers: response_header,
363
- });
53
+ return new Response(null, { status: 204, headers: shortener.htmlHeaders() });
364
54
  }
365
-
366
55
  if (request.method === "POST") {
367
- return await handlePostRequest(request, requestURL, pathname);
56
+ return shortener.handleShorten(request, requestURL, {
57
+ apiPath: requestURL.pathname,
58
+ allowedApiPaths: ["/"],
59
+ });
368
60
  }
369
-
370
- // Handle GET requests (and any other methods)
371
- return await handleGetRequest(requestURL);
61
+ if (!path) return shortener.fetchHostedPage(getEndpoints().shortenPage);
62
+ return shortener.handleShortUrlRedirect(path, params);
372
63
  }
373
64
 
374
- addEventListener("fetch", async (event) => {
375
- event.respondWith(handleRequest(event.request));
376
- });
65
+ export default withFetchObservability(exampleFetchHandler);
package/config/config.js DELETED
@@ -1,101 +0,0 @@
1
- /**
2
- * URL Shortener Configuration
3
- *
4
- * This file contains all configurable settings for the URL shortener.
5
- * Copy this file from config.example.js and modify the values as needed.
6
- */
7
-
8
- const config = {
9
- // Frontend Configuration
10
- frontend: {
11
- // URL where your frontend is hosted (used by worker to serve the main page)
12
- url: "https://dytsou.github.io/shorten-url/",
13
-
14
- // Domain displayed in the custom slug input prefix
15
- // If null, will use the current domain automatically
16
- displayDomain: null,
17
-
18
- // Default theme for the frontend
19
- theme: "", // Use empty string for default theme, or "theme/urlcool" for urlcool theme
20
- },
21
-
22
- // Worker Configuration
23
- worker: {
24
- // Control the HTTP referrer header
25
- // Set to "on" to create anonymous links that hide the HTTP Referer header
26
- no_ref: "off",
27
-
28
- // Allow Cross-origin resource sharing for API requests
29
- cors: "on",
30
-
31
- // If true, the same long URL will be shortened into the same short URL
32
- unique_link: true,
33
-
34
- // Allow users to customize the short URL
35
- custom_link: true,
36
-
37
- // Google Safe Browsing API Key for URL safety check before redirect
38
- // Leave empty to disable safety checking
39
- safe_browsing_api_key: "",
40
-
41
- // Maximum length for custom slugs
42
- max_custom_slug_length: 50,
43
-
44
- // Minimum length for generated random keys
45
- min_random_key_length: 6,
46
-
47
- // Characters used for random key generation (removed confusing characters)
48
- random_chars: 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678',
49
-
50
- // Reserved slugs that cannot be used as custom URLs
51
- reserved_slugs: ['api', 'admin', 'www', 'mail', 'ftp', 'localhost', 'password', 'help', 'support', 'contact', 'about']
52
- },
53
-
54
- // KV Storage Configuration (Cloudflare Workers)
55
- storage: {
56
- // KV namespace binding name (must match wrangler.toml)
57
- binding_name: "LINKS"
58
- },
59
-
60
- // Security Configuration
61
- security: {
62
- // Rate limiting (requests per minute per IP)
63
- rate_limit: 10,
64
-
65
- // Enable URL validation
66
- validate_urls: true,
67
-
68
- // Block suspicious domains
69
- block_suspicious_domains: true,
70
-
71
- // List of blocked domains (exact matches)
72
- blocked_domains: [
73
- "bit.ly",
74
- "tinyurl.com",
75
- "t.co",
76
- "reurl.cc"
77
- ]
78
- },
79
-
80
- // Analytics Configuration (optional)
81
- analytics: {
82
- // Enable click tracking
83
- enable_tracking: false,
84
-
85
- // Google Analytics tracking ID
86
- ga_tracking_id: "",
87
-
88
- // Custom analytics endpoint
89
- analytics_endpoint: ""
90
- }
91
- };
92
-
93
- // Export for use in other files
94
- if (typeof module !== 'undefined' && module.exports) {
95
- module.exports = config;
96
- }
97
-
98
- // Make available globally for browser usage
99
- if (typeof window !== 'undefined') {
100
- window.APP_CONFIG = config;
101
- }