[短链][Worker]URL Shortener无需服务器轻松部署,可绑定自定义域名

Github项目

这是一个基于 CloudFlare Worker 部署的链接缩短器,无需服务器轻松部署,可绑定自定义域名。

本分支功能特性:

  • 支持 CloudFlare Worker 环境变量配置参数。

  • 支持权限分级,可设置管理员与访客密码(访问路径),可对访客设置权限限制。

  • 支持对未授权用户、访客用户及管理员用户设置不同的主页。

  • 支持配置正则表达式规则。

  • 可在网页中缓存并管理生成的记录。@crazypeace

  • 可下载全部生成的记录并缓存到本地。@crazypeace

  • PWA 特性支持。

  • 其他细节改进。

部署

通过 Cloudflare 部署,如果你的域名托管在 Cloudflare 则可以绑定你的域名。

创建 KV

创建一个 KV Namespace。

部署 Worker

创建一个 Worker。

去 Worker => Worker 名字 => Variables => KV Namespace Bindings 。

其中 Variable name 填写 LINKS, KV namespace 填写你刚刚创建的命名空间。

Variable nameKV namespace

LINKS

填写创建的KV空间名称

点击 Edit Code,复制本项目中的 index.js 的代码到 Cloudflare Worker 。

index.js
const repo_version = typeof(REPO_VERSION) != "undefined" ? REPO_VERSION :
  "@gh-pages"
// Admin user password.
const password_value_admin = typeof(PASSWORD_ADMIN) != "undefined" ? PASSWORD_ADMIN :
  "admin"
// Guest user password.
const password_value = typeof(PASSWORD) != "undefined" ? PASSWORD :
  ""
// Redirect the homepage if it defined.
const index_redirect = typeof(INDEX_REDIRECT) != "undefined" ? INDEX_REDIRECT :
  ""
// The URL of the deployed website.
const url_exclude = typeof(URL_EXCLUDE) != "undefined" ? URL_EXCLUDE :
  "//url-shortner-demo.iou.icu"
// Homepage path for admin user, use the empty value for default theme.
const theme_admin = typeof(THEME_ADMIN) != "undefined" ? THEME_ADMIN :
  ""
// Homepage path for guest user, use the empty value for default theme.
const theme = typeof(THEME) != "undefined" ? THEME :
  ""
const len = typeof(LEN) != "undefined" ? parseInt(LEN) :
  6
// Control the HTTP referrer header, if you want to create an anonymous link that will hide the HTTP Referer header, please set to "true" .
const no_ref = typeof(NO_REF) != "undefined" ? NO_REF :
  "false"
// Allow Cross-origin resource sharing for API requests.
const cors = typeof(CORS) != "undefined" ? CORS :
  "false"
// For all users. If it is true, the same long url will be shorten into the same short url.
const unique_link = typeof(UNIQUE_LINK) != "undefined" ? UNIQUE_LINK :
  "false"
// For guest user only. Allow users to customize the short url.
const custom_link = typeof(CUSTOM_LINK) != "undefined" ? CUSTOM_LINK :
  "true"
// For guest user only.
const len_limit = typeof(LEN_LIMIT) != "undefined" ? parseInt(LEN_LIMIT) :
  3
// Enable the regular expression redirec.
// The regular expression is configured in json format in #regexRedirect key in KV.
// Regex matching has higher priority than path-value matching.
const regex_redirect = typeof(REGEX_REDIRECT) != "undefined" ? REGEX_REDIRECT :
  "false"
const regex_key = "#regexRedirect";

const html404 = `<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx</center>
</body>
</html>`

let response_header = {
  "content-type": "text/html;charset=UTF-8",
}

if (cors == "true") {
  response_header = {
    "content-type": "text/html;charset=UTF-8",
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Methods": "POST",
  }
}

async function randomString(len) {
  len = len || 6;
  let $chars = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"; /****默认去掉了容易混淆的字符oOLl,9gq,Vv,Uu,I1****/
  let maxPos = $chars.length;
  let result = "";
  for (i = 0; i < len; i++) {
    result += $chars.charAt(Math.floor(Math.random() * maxPos));
  }
  return result;
}

async function sha512(url) {
  url = new TextEncoder().encode(url)

  const url_digest = await crypto.subtle.digest({
      name: "SHA-512",
    },
    url, // The data you want to hash as an ArrayBuffer
  )
  const hashArray = Array.from(new Uint8Array(url_digest)); // convert buffer to byte array
  const hashHex = hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
  // console.log(hashHex)
  return hashHex
}
async function checkURL(URL) {
  let str = URL;
  let Expression = /http(s)?:\/\/([\w-]+\.)+[\w-]+(\/[\w- .\/?%&=]*)?/;
  let objExp = new RegExp(Expression);
  if (objExp.test(str) == true) {
    if (str[0] == "h")
      return true;
    else
      return false;
  } else {
    return false;
  }
}
async function save_url(URL) {
  let random_key = await randomString()
  let is_exist = await LINKS.get(random_key)
  console.log(is_exist)
  if (is_exist == null)
    return await LINKS.put(random_key, URL), random_key
  else
    save_url(URL)
}
async function is_url_exist(url_sha512) {
  let is_exist = await LINKS.get(url_sha512)
  console.log(is_exist)
  if (is_exist == null) {
    return false
  } else {
    return is_exist
  }
}
async function handleRequest(request) {
  console.log(request)

  if (request.method === "POST") {
    let req = await request.json()

    let req_password = req["password"]
    let user
    if (password_value_admin && req_password === password_value_admin) {
      user = 10; // Admin user
    } else if (!password_value || req_password === password_value) {
      user = 1; // Guest user
    } else {
      user = 0; // Unauthorized user
    }

    let req_cmd = req["cmd"]
    if (req_cmd == "add") {
      let req_url = req["url"]
      let req_keyPhrase = req["keyPhrase"]
      let req_keyPhLen = req_keyPhrase.length

      // console.log("req_url:"+req_url)
      // console.log("req_keyPhrase:"+req_keyPhrase)
      // console.log("req_password:"+req_password)
      // console.log("user:"+user)

      if (user === 0) {
        // Incorrect password.
        return new Response(`{"status":500,"key": "", "error":"Error: Invalid password."}`, {
          headers: response_header,
        })
      }
      if (user === 1) {
        if (!await checkURL(req_url)) {
          return new Response(`{"status":500,"key": "", "error":"Error: URL illegal."}`, {
            headers: response_header,
          })
        }
        if (req_url.indexOf(url_exclude) != -1) {
          return new Response(`{"status":500,"key": "", "error":"Error: URL illegal."}`, {
            headers: response_header,
          })
        }
        // req_keyPhrase containing symbol.
        if (req_keyPhrase && !/^[a-zA-Z0-9]+$/.test(req_keyPhrase)) {
          return new Response(`{"status":500,"key": "", "error":"Error: Custom short URL illegal."}`, {
            headers: response_header,
          })
        }
        if (req_keyPhLen < len_limit && req_keyPhLen > 0) {
          return new Response(`{"status":500,"key": "", "error":"Error: Custom short URL is too short."}`, {
            headers: response_header,
          })
        }
      }

      // Custom short URL existed.
      let stat, random_key
      if (custom_link == "true" && (req_keyPhrase != "")) {
        let is_exist = await LINKS.get(req_keyPhrase)
        if (is_exist != null && user <= 1) {
          return new Response(`{"status":500,"key": "", "error":"Error: Custom short URL is not available."}`, {
            headers: response_header,
          })
        } else {
          random_key = req_keyPhrase
          stat, await LINKS.put(req_keyPhrase, req_url)
        }
      } else if (unique_link == "true") {
        let url_sha512 = await sha512(req_url)
        let url_key = await is_url_exist(url_sha512)
        if (url_key) {
          random_key = url_key
        } else {
          stat,
          random_key = await save_url(req_url);
          if (typeof(stat) == "undefined") {
            // console.log(await LINKS.put(url_sha512,random_key))
          }
        }
      } else {
        stat,
        random_key = await save_url(req_url);
      }
      // console.log(stat)
      if (typeof(stat) == "undefined") {
        return new Response(`{"status":200, "key":"` + random_key + `", "error": ""}`, {
          headers: response_header,
        })
      } else {
        return new Response(`{"status":500, "key": "", "error":"Error: Reach the KV write limitation."}`, {
          headers: response_header,
        })
      }

      // Delete a single KV record.
    } else if (req_cmd == "del") {
      let req_keyPhrase = req["keyPhrase"]

      if (user == 0) {
        return new Response(`{"status":500,"key": "", "error":"Error: Invalid password."}`, {
          headers: response_header,
        })
      }

      await LINKS.delete(req_keyPhrase)
      return new Response(`{"status":200}`, {
        headers: response_header,
      })

      // Load all KV records.
    } else if (req_cmd == "qryall") {
      if (user !== 10) {
        return new Response(`{"status":500, "error":"Error: Invalid password."}`, {
          headers: response_header,
        })
      }
      let keyList = await LINKS.list()
      if (keyList != null) {
        // 初始化返回数据结构 Init the return struct
        let jsonObjectRetrun = JSON.parse(`{"status":200, "error":"", "kvlist": []}`);

        for (var i = 0; i < keyList.keys.length; i++) {
          let item = keyList.keys[i];

          let url = await LINKS.get(item.name);

          let newElement = {
            "key": item.name,
            "value": url
          };
          // 填充要返回的列表 Fill the return list
          jsonObjectRetrun.kvlist.push(newElement);
        }

        return new Response(JSON.stringify(jsonObjectRetrun), {
          headers: response_header,
        })
      } else {
        return new Response(`{"status":500, "error":"Error: Download records failed."}`, {
          headers: response_header,
        })
      }
    }

  } else if (request.method === "OPTIONS") {
    return new Response(``, {
      headers: response_header,
    })
  }

  const requestURL = new URL(request.url)
  const path = requestURL.pathname.split("/")[1]
  const params = requestURL.search;

  console.log(path)
  // Redirect the homepage.
  if (!path && index_redirect) {
    return Response.redirect(index_redirect, 302)
  }

  // Admin user homepage.
  if (password_value_admin && path == password_value_admin) {
    let index = await fetch("https://cdn.jsdelivr.net/gh/Monopink/Url-Shorten-Worker" + repo_version + "/" + theme_admin + "/index.html")
    index = await index.text()
    index = index.replaceAll(/__REPO_VERSION__/gm, repo_version)
    index = index.replaceAll(/__PASSWORD__/gm, path)
    return new Response(index, {
      headers: {
        "content-type": "text/html;charset=UTF-8",
      },
    })
  }

  // Guest user homepage.
  if ((!path && !password_value) || path == password_value) {
    let index = await fetch("https://cdn.jsdelivr.net/gh/Monopink/Url-Shorten-Worker" + repo_version + "/" + theme + "/index.html")
    index = await index.text()
    index = index.replaceAll(/__REPO_VERSION__/gm, repo_version)
    index = index.replaceAll(/__PASSWORD__/gm, path)
    return new Response(index, {
      headers: {
        "content-type": "text/html;charset=UTF-8",
      },
    })
  } else if (!path) {
    return new Response(html404, {
      headers: {
        "content-type": "text/html;charset=UTF-8",
      },
      status: 404
    })
  }

  let location;

  if (regex_redirect == "true") {
    try {
      const regexJson = await LINKS.get(regex_key);
      const regexDict = JSON.parse(regexJson);

      for (const pattern in regexDict) {
        const regex = new RegExp(pattern);
        if (regex.test(path)) {
          location = path.replace(regex, regexDict[pattern]);
          continue;
        }

      }
    } catch(err){
      console.log(err);
    }
  }

  // Not hit by regex.
  if (!location) {
    const value = await LINKS.get(path);
    location = params ? value + params : value;
  }

  // console.log(value)

  if (location) {
    if (no_ref == "true") {
      let no_ref = await fetch("https://Monopink.github.io/Url-Shorten-Worker/no-ref.html")
      no_ref = await no_ref.text()
      no_ref = no_ref.replace(/{Replace}/gm, location)
      return new Response(no_ref, {
        headers: {
          "content-type": "text/html;charset=UTF-8",
        },
      })
    } else {
      return Response.redirect(location, 302)
    }
  }
  // If request not in kv, return 404
  return new Response(html404, {
    headers: {
      "content-type": "text/html;charset=UTF-8",
    },
    status: 404
  })
}

addEventListener("fetch", async event => {
  event.respondWith(handleRequest(event.request))
})

点击 Deploy。

绑定域名

去 Worker => Worker 名字 => Triggers => Routes 来绑定你自己的域名来访问。

环境变量

去 Worker => Worker 名字 => Variables => Environment Variables 来配置环境变量。

变量名称值(默认)说明

REPO_VERSION

@gh-pages

前端页面仓库版本,如果使用 Jsdelivr CDN 地址则可能需要改为 Release tag,否则可能不是最新版本

PASSWORD_ADMIN

admin

管理员用户密码(访问路径),值为空表示无管理员用户

PASSWORD

访客用户密码(访问路径),值为空表示主页

INDEX_REDIRECT

访客用户密码值不为空时,主页跳转的 URL

URL_EXCLUDE

//zdy.ym

排除本机域名,请修改为你的域名

THEME_ADMIN

管理员用户主页路径,如:theme/admin

THEME

访客用户主页

LEN

6

随机生成的短链路径长度

NO_REF

false

控制 HTTP referrer header

CORS

false

允许 API 请求提供跨源资源共享

UNIQUE_LINK

false

为相同的 URL 生成相同的短链

CUSTOM_LINK

true

允许访客用户自定义短链

LEN_LIMIT

3

允许访客用户自定义短链的最小长度

REGEX_REDIRECT

false

开启正则表达式重定向功能

正则表达式重定向

启用正则表达式重定向功能请先将环境变量 EGEX_REDIRECT 设置为 true

正则表达式以 json 的格式存储在 KV #regexRedirect 键中,像这样:

Key = #regexRedirect
Value = {"^(example.*)": "https://www.iou.icu/$1","^gg\\.(.*)":"https://www.google.com/search?q=$1"}

在运行时会被转为字典,字典的键为正则表达式匹配规则,值为替换规则。

这条记录代表着有两条正则规则:

规则一

查找:^(example.*)

替换:https://www.iou.icu/$1

规则二

查找:^gg\.(.*)

替换:https://www.google.com/search?q=$1

传入的短链接会依次匹配,应用第一个匹配的规则。

例如传入短链接 https://example.com/example-apple,重定向结果为 https://www.iou.icu/example-apple

如果是 https://example.com/gg.apple ,将跳转 https://www.google.com/search?q=apple。你会得到一个快捷搜索。

正则表达式优先于短链接,请确保 json 格式正确并做好转义。

Last updated