├── 404.html ├── LICENSE ├── README.md └── index.js /404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 13 | 简短分享 - 长网址缩短,文本分享,Html单页分享 14 | 15 | 16 | 81 | 82 | 83 | 84 |
85 | 90 |
91 | 92 |
93 |
94 | 110 |
111 | 112 |
113 |

114 | 115 |
116 |
117 | 135 |
136 |
137 |
138 |
139 |

140 | 前往自行部署 141 |

142 |
143 | 144 |
145 | 146 | 152 | 153 | 154 | 155 | 156 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Oliver Git 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 简约短链接生成器 2 | 3 | 一个基于 Cloudflare Workers 和 KV 存储的短链接生成服务。 4 | 因为[Cloudflare](https://www.cloudflare.com)的免费套餐有限制,所以不提供预览地址,请自行前往[cloudflare Work](https://dash.cloudflare.com)部署。 5 | 正常来说免费套餐私人使用是完全够的,如有大量需求可付费升级套餐。 6 | 7 | ## 功能特点 8 | 9 | - 🔗 生成短链接 10 | - 🔒 支持密码保护 11 | - ⏰ 支持链接有效期设置 12 | - 🔢 支持访问次数限制 13 | - 🤖 集成 Cloudflare Turnstile 人机验证 14 | - 🎨 简洁美观的用户界面 15 | - ✨ 支持自定义短链接 16 | 17 | ## 部署步骤 18 | 19 | ### 1. 准备工作 20 | 21 | - 注册 [Cloudflare](https://dash.cloudflare.com) 账号 22 | ------- 23 | - 去Workers KV中创建一个命名空间 24 | ![image](https://github.com/user-attachments/assets/eb761e5d-bdfa-4ef6-8c8f-d347bd27daed) 25 | 26 | - 去Worker的设置选选项卡中绑定KV 命名空间 27 | 28 | - 其中变量名称填写`URL_SHORT_KV`, KV 命名空间填写你刚刚创建的命名空间 29 | 30 | ![image](https://github.com/user-attachments/assets/68db428a-c3af-42f7-90fc-43ba91f9cc7b) 31 | 32 | 复制本项目中的[index.js](/index.js)的代码到Cloudflare Worker 点击保存并部署 33 | 34 | ### 2. 配置 Turnstile 35 | 36 | 启用人机验证: 37 | 38 | 1. 在 [Cloudflare Dashboard](https://dash.cloudflare.com/?to=/:account/turnstile) 创建新的 Turnstile site key 39 | 2. 获取 site key 和 secret key 40 | 3. 在 Workers 设置中添加环境变量: 41 | - `TURNSTILE_SITE_KEY`: 你的 site key 42 | - `TURNSTILE_SECRET`: 你的 secret key 43 | 44 | ## 预览图 45 | 46 | ![image](https://github.com/user-attachments/assets/25d3c304-3b25-485a-b158-29d795439cbd) 47 | 48 | ## 使用说明 49 | 50 | 1. 访问你的 Worker URL (例如: `https://url-shortener.你的用户名.workers.dev`) 51 | 2. 输入需要缩短的链接 52 | 3. (可选) 设置: 53 | - 自定义短链接 54 | - 有效期 55 | - 访问密码 56 | - 最大访问次数 57 | 4. 点击生成按钮获取短链接 58 | 59 | ## 注意事项 60 | #### Workers 61 | 每个请求最多占用 10 毫秒 CPU 时间 62 | 第一个请求后的延迟最低 63 | 每天最多 100,000 个请求 (UTC+0) 64 | #### KV 65 | 全局性的低延迟键值边缘存储 66 | 每天最多 100,000 次读取操作 67 | 每天最多 1,000 次写入、删除和列出操作 68 | 69 | ## 许可证 70 | 71 | MIT License 72 | 73 | ## 感谢 74 | 感谢[Cloudflare](https://www.cloudflare.com)提供平台和服务。 75 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // Cloudflare Worker 2 | // This worker uses Cloudflare KV for storing URL data 3 | 4 | addEventListener("fetch", (event) => { 5 | event.respondWith(handleRequest(event.request)); 6 | }); 7 | 8 | async function handleRequest(request) { 9 | try { 10 | const url = new URL(request.url); 11 | const { pathname } = url; 12 | 13 | // Handle favicon request 14 | if (pathname === '/favicon.ico') { 15 | return new Response(null, { status: 204 }); 16 | } 17 | 18 | if (pathname === "/") { 19 | // Serve the frontend 20 | return serveFrontend(); 21 | } 22 | 23 | if (pathname.startsWith("/api")) { 24 | // Handle API requests 25 | return handleAPIRequest(request); 26 | } 27 | 28 | // Redirect for short URLs 29 | return handleRedirect(pathname); 30 | } catch (error) { 31 | console.error('Error handling request:', error); 32 | return new Response('服务器内部错误', { status: 500 }); 33 | } 34 | } 35 | 36 | async function serveFrontend() { 37 | const turnstileScript = TURNSTILE_SITE_KEY ? 38 | '' : 39 | ''; 40 | 41 | const frontendHTML = ` 42 | 43 | 44 | 45 | 46 | 短链接生成器 47 | 48 | 49 | ${turnstileScript} 50 | 51 | 52 |
53 |
54 |

55 | 57 | 简约短链 58 | 59 |

60 |

简单、安全的链接缩短服务

61 | 64 | 67 | 自部署开源地址 68 | 69 |
70 | 71 |
72 |
73 |
74 |
75 | 79 | 82 |
83 | 84 |
85 |
86 | 90 | 93 |
94 |
95 | 99 | 108 | 110 |
111 |
112 | 113 |
114 |
115 | 119 | 122 |
123 |
124 | 128 | 131 |
132 |
133 |
134 | 135 |
136 | ${TURNSTILE_SITE_KEY ? 137 | '
' : 138 | ''} 139 |
140 | 141 | 145 |
146 | 147 |
148 |
149 |
150 | 151 | 286 | 287 | `; 288 | 289 | return new Response(frontendHTML, { 290 | headers: { 291 | "Content-Type": "text/html", 292 | "Cache-Control": "no-cache, no-store, must-revalidate" 293 | }, 294 | }); 295 | } 296 | 297 | async function handleAPIRequest(request) { 298 | try { 299 | const { pathname } = new URL(request.url); 300 | 301 | if (pathname === "/api/shorten") { 302 | if (request.method !== "POST") { 303 | return new Response(JSON.stringify({ error: "请求方法不允许" }), { 304 | status: 405, 305 | headers: { 306 | "Content-Type": "application/json", 307 | "Allow": "POST" 308 | } 309 | }); 310 | } 311 | 312 | const { url, slug, expiry, password, maxVisits, token } = await request.json(); 313 | if (!url) { 314 | return new Response(JSON.stringify({ error: "请输入链接地址" }), { 315 | status: 400, 316 | headers: { "Content-Type": "application/json" }, 317 | }); 318 | } 319 | 320 | // Validate URL 321 | try { 322 | new URL(url); 323 | } catch { 324 | return new Response(JSON.stringify({ error: "链接格式无效" }), { 325 | status: 400, 326 | headers: { "Content-Type": "application/json" }, 327 | }); 328 | } 329 | 330 | // 添加最大访问次数验证 331 | if (maxVisits && (parseInt(maxVisits) <= 0 || isNaN(parseInt(maxVisits)))) { 332 | return new Response(JSON.stringify({ error: "最大访问次数必须大于0" }), { 333 | status: 400, 334 | headers: { "Content-Type": "application/json" }, 335 | }); 336 | } 337 | 338 | // 添加自定义有效期验证 339 | if (expiry) { 340 | const expiryDate = new Date(expiry); 341 | const now = new Date(); 342 | if (expiryDate <= now) { 343 | return new Response(JSON.stringify({ error: "有效期必须大于当前时间" }), { 344 | status: 400, 345 | headers: { "Content-Type": "application/json" }, 346 | }); 347 | } 348 | } 349 | 350 | // 移除URL检查代码,直接生成新的短链接 351 | const shortSlug = slug || generateSlug(); 352 | 353 | // 添加自定义短链接长度验证 354 | if (slug && slug.length < 3) { 355 | return new Response(JSON.stringify({ error: "自定义链接至少需要3个字符" }), { 356 | status: 400, 357 | headers: { "Content-Type": "application/json" }, 358 | }); 359 | } 360 | 361 | // Validate slug format 362 | if (!/^[a-zA-Z0-9-_]+$/.test(shortSlug)) { 363 | return new Response(JSON.stringify({ error: "自定义链接格式无效,只能使用字母、数字、横线和下划线" }), { 364 | status: 400, 365 | headers: { "Content-Type": "application/json" }, 366 | }); 367 | } 368 | 369 | const existing = await URL_SHORT_KV.get(shortSlug); 370 | if (existing) { 371 | return new Response(JSON.stringify({ error: "该自定义链接已被使用" }), { 372 | status: 400, 373 | headers: { "Content-Type": "application/json" }, 374 | }); 375 | } 376 | 377 | const expiryTimestamp = expiry ? new Date(expiry).getTime() : null; 378 | await URL_SHORT_KV.put(shortSlug, JSON.stringify({ 379 | url, 380 | expiry: expiryTimestamp, 381 | password, 382 | created: Date.now(), 383 | maxVisits: maxVisits ? parseInt(maxVisits) : null, 384 | visits: 0 385 | })); 386 | 387 | const baseURL = new URL(request.url).origin; 388 | const shortURL = `${baseURL}/${shortSlug}`; 389 | return new Response(JSON.stringify({ shortened: shortURL }), { 390 | headers: { "Content-Type": "application/json" }, 391 | }); 392 | } 393 | 394 | if (pathname.startsWith('/api/verify/')) { 395 | if (request.method !== "POST") { 396 | return new Response(JSON.stringify({ error: "请求方法不允许" }), { 397 | status: 405, 398 | headers: { 399 | "Content-Type": "application/json", 400 | "Allow": "POST" 401 | } 402 | }); 403 | } 404 | 405 | const slug = pathname.replace('/api/verify/', ''); 406 | const record = await URL_SHORT_KV.get(slug); 407 | 408 | if (!record) { 409 | return new Response(JSON.stringify({ error: "链接不存在" }), { 410 | status: 404, 411 | headers: { "Content-Type": "application/json" } 412 | }); 413 | } 414 | 415 | const { password: correctPassword, url, maxVisits, visits = 0 } = JSON.parse(record); 416 | const { password: inputPassword, token } = await request.json(); 417 | 418 | // 验证 Turnstile token 419 | if (TURNSTILE_SITE_KEY && TURNSTILE_SECRET) { 420 | if (!token) { 421 | return new Response(JSON.stringify({ error: "请完成人机验证" }), { 422 | status: 400, 423 | headers: { "Content-Type": "application/json" }, 424 | }); 425 | } 426 | 427 | const tokenValidation = await validateTurnstileToken(token); 428 | if (!tokenValidation.success) { 429 | return new Response(JSON.stringify({ error: "人机验证失败" }), { 430 | status: 400, 431 | headers: { "Content-Type": "application/json" }, 432 | }); 433 | } 434 | } 435 | 436 | if (inputPassword === correctPassword) { 437 | if (maxVisits) { 438 | const newVisits = visits + 1; 439 | await URL_SHORT_KV.put(slug, JSON.stringify({ 440 | ...JSON.parse(record), 441 | visits: newVisits 442 | })); 443 | } 444 | 445 | return new Response(JSON.stringify({ 446 | success: true, 447 | url: url 448 | }), { 449 | headers: { "Content-Type": "application/json" } 450 | }); 451 | } else { 452 | return new Response(JSON.stringify({ 453 | success: false, 454 | error: "密码错误" 455 | }), { 456 | headers: { "Content-Type": "application/json" } 457 | }); 458 | } 459 | } 460 | 461 | return new Response(JSON.stringify({ error: "页面不存在" }), { 462 | status: 404, 463 | headers: { "Content-Type": "application/json" } 464 | }); 465 | } catch (error) { 466 | console.error('API Error:', error); 467 | return new Response(JSON.stringify({ error: "服务器内部错误" }), { 468 | status: 500, 469 | headers: { "Content-Type": "application/json" }, 470 | }); 471 | } 472 | } 473 | 474 | async function handleRedirect(pathname) { 475 | try { 476 | const slug = pathname.slice(1); 477 | const record = await URL_SHORT_KV.get(slug); 478 | 479 | if (!record) { 480 | return new Response("链接不存在", { 481 | status: 404, 482 | headers: { "Content-Type": "text/plain; charset=utf-8" } 483 | }); 484 | } 485 | 486 | const data = JSON.parse(record); 487 | const { url, expiry, password, maxVisits, visits = 0 } = data; 488 | 489 | if (expiry && Date.now() > expiry) { 490 | await URL_SHORT_KV.delete(slug); 491 | return new Response("链接已过期", { 492 | status: 410, 493 | headers: { "Content-Type": "text/plain; charset=utf-8" } 494 | }); 495 | } 496 | 497 | if (maxVisits && visits >= maxVisits) { 498 | await URL_SHORT_KV.delete(slug); 499 | return new Response("链接访问次数已达上限", { 500 | status: 410, 501 | headers: { "Content-Type": "text/plain; charset=utf-8" } 502 | }); 503 | } 504 | 505 | // 只在没有密码保护时更新访问次数 506 | if (maxVisits && !password) { 507 | data.visits = visits + 1; 508 | await URL_SHORT_KV.put(slug, JSON.stringify(data)); 509 | } 510 | 511 | if (password) { 512 | const turnstileScript = TURNSTILE_SITE_KEY ? 513 | '' : 514 | ''; 515 | 516 | const frontendHTML = ` 517 | 518 | 519 | 520 | 521 | 密码保护链接 522 | 523 | 524 | ${turnstileScript} 525 | 526 | 527 |
528 |
529 |

密码保护链接

530 |
531 |
532 | 533 | 534 |
535 |
536 | ${TURNSTILE_SITE_KEY ? 537 | '
' : 538 | ''} 539 |
540 | 543 |
544 |
545 |
546 |
547 | 595 | 596 | `; 597 | 598 | return new Response(frontendHTML, { 599 | headers: { 600 | "Content-Type": "text/html", 601 | "Cache-Control": "no-cache, no-store, must-revalidate" 602 | }, 603 | }); 604 | } 605 | 606 | return Response.redirect(url, 302); 607 | } catch (error) { 608 | console.error('Redirect Error:', error); 609 | return new Response("服务器内部错误", { 610 | status: 500, 611 | headers: { "Content-Type": "text/plain; charset=utf-8" } 612 | }); 613 | } 614 | } 615 | 616 | function generateSlug(length = 6) { 617 | const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 618 | return Array.from({ length }, () => chars[Math.floor(Math.random() * chars.length)]).join(""); 619 | } 620 | 621 | function onloadTurnstileCallback() { 622 | console.log('Turnstile loaded successfully'); 623 | } 624 | 625 | async function validateTurnstileToken(token) { 626 | try { 627 | const formData = new FormData(); 628 | formData.append('secret', TURNSTILE_SECRET); 629 | formData.append('response', token); 630 | 631 | const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { 632 | method: 'POST', 633 | body: formData 634 | }); 635 | 636 | const data = await response.json(); 637 | return { 638 | success: data.success, 639 | error: data['error-codes'] 640 | }; 641 | } catch (error) { 642 | console.error('Turnstile validation error:', error); 643 | return { 644 | success: false, 645 | error: ['验证服务器错误'] 646 | }; 647 | } 648 | } 649 | --------------------------------------------------------------------------------