@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 +38 -34
- package/package.json +7 -4
- package/src/lib/access.js +5 -0
- package/src/lib/constants.js +2 -0
- package/src/lib/crypto.js +18 -0
- package/src/lib/endpoints.js +12 -0
- package/src/lib/kv.js +49 -0
- package/src/lib/observability.js +86 -0
- package/src/lib/responses.js +63 -0
- package/src/lib/safety.js +28 -0
- package/src/lib/shortener.js +119 -0
- package/src/lib/validate.js +24 -0
- package/src/worker.example.js +44 -355
- package/config/config.js +0 -101
package/docs/index.html
CHANGED
|
@@ -29,13 +29,9 @@
|
|
|
29
29
|
|
|
30
30
|
h1 {
|
|
31
31
|
text-align: center;
|
|
32
|
-
color: #
|
|
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, #
|
|
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: #
|
|
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: #
|
|
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: #
|
|
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 || "#
|
|
586
|
+
const originalBg = copyBtn.style.backgroundColor || "#157347";
|
|
577
587
|
|
|
578
588
|
if (!shortUrlInput.value) {
|
|
579
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
610
|
+
showButtonFeedback(copyBtn, "Select & Copy", "#ffc107", originalText, originalBg, 3000);
|
|
601
611
|
}
|
|
602
612
|
}
|
|
603
613
|
|
|
604
|
-
// Helper function for
|
|
605
|
-
function
|
|
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
|
-
|
|
644
|
+
showButtonFeedback(gotoBtn, "Enter URL first!", "#dc3545", originalText, originalBg);
|
|
628
645
|
return;
|
|
629
646
|
}
|
|
630
647
|
|
|
631
648
|
// Show processing state
|
|
632
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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": ">=
|
|
25
|
+
"node": ">=24.0.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"prettier": "^3.7.4",
|
|
29
|
-
"
|
|
30
|
-
"
|
|
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,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
|
+
}
|
package/src/worker.example.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
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
|
|
56
|
+
return shortener.handleShorten(request, requestURL, {
|
|
57
|
+
apiPath: requestURL.pathname,
|
|
58
|
+
allowedApiPaths: ["/"],
|
|
59
|
+
});
|
|
368
60
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
return await handleGetRequest(requestURL);
|
|
61
|
+
if (!path) return shortener.fetchHostedPage(getEndpoints().shortenPage);
|
|
62
|
+
return shortener.handleShortUrlRedirect(path, params);
|
|
372
63
|
}
|
|
373
64
|
|
|
374
|
-
|
|
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
|
-
}
|