@dytsou/shorten-url 2.2.6
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/LICENSE +22 -0
- package/README.md +359 -0
- package/config/config.example.js +118 -0
- package/config/config.js +101 -0
- package/docs/api/index.html +34 -0
- package/docs/api/openapi.yaml +150 -0
- package/docs/index.html +728 -0
- package/package.json +52 -0
- package/src/worker.example.js +376 -0
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dytsou/shorten-url",
|
|
3
|
+
"version": "2.2.6",
|
|
4
|
+
"description": "A modern, fast URL shortener built with Cloudflare Workers and a responsive web interface",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/worker.js",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"url-shortener",
|
|
9
|
+
"cloudflare-workers",
|
|
10
|
+
"short-url",
|
|
11
|
+
"link-shortener",
|
|
12
|
+
"url-shortening"
|
|
13
|
+
],
|
|
14
|
+
"author": "dytsou",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/dytsou/shorten-url.git"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/dytsou/shorten-url/issues"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/dytsou/shorten-url#readme",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18.0.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"prettier": "^3.7.4",
|
|
29
|
+
"vitest": "^4.0.16",
|
|
30
|
+
"wrangler": "^4.58.0"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"src/worker.js",
|
|
34
|
+
"src/worker.example.js",
|
|
35
|
+
"config/",
|
|
36
|
+
"docs/",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"dev": "wrangler dev",
|
|
42
|
+
"deploy": "wrangler deploy",
|
|
43
|
+
"preview": "wrangler dev",
|
|
44
|
+
"publish:npmjs": "bash scripts/publish-npmjs.sh",
|
|
45
|
+
"publish:github": "pnpm publish --registry https://npm.pkg.github.com",
|
|
46
|
+
"format": "prettier --write .",
|
|
47
|
+
"format:check": "prettier --check .",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"test:watch": "vitest",
|
|
50
|
+
"test:coverage": "vitest run --coverage"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
let config;
|
|
2
|
+
|
|
3
|
+
try {
|
|
4
|
+
// In a real deployment, you might want to fetch this from KV storage
|
|
5
|
+
// or use environment variables for sensitive data
|
|
6
|
+
if (typeof importConfig !== "undefined") {
|
|
7
|
+
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
|
+
} else {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
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
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
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,
|
|
257
|
+
});
|
|
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
|
+
|
|
355
|
+
const requestURL = new URL(request.url);
|
|
356
|
+
const pathname = requestURL.pathname;
|
|
357
|
+
|
|
358
|
+
// CORS preflight
|
|
359
|
+
if (request.method === "OPTIONS") {
|
|
360
|
+
return new Response("", {
|
|
361
|
+
status: 204,
|
|
362
|
+
headers: response_header,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (request.method === "POST") {
|
|
367
|
+
return await handlePostRequest(request, requestURL, pathname);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Handle GET requests (and any other methods)
|
|
371
|
+
return await handleGetRequest(requestURL);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
addEventListener("fetch", async (event) => {
|
|
375
|
+
event.respondWith(handleRequest(event.request));
|
|
376
|
+
});
|