@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/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
+ });