[{"data":1,"prerenderedAt":387},["ShallowReactive",2],{"content:\u002F2026\u002Fexternal-link-jump-page":3,"surround:\u002F2026\u002Fexternal-link-jump-page":381},{"id":4,"title":5,"body":6,"categories":355,"date":357,"description":358,"draft":359,"extension":360,"image":361,"meta":362,"navigation":364,"path":365,"permalink":361,"published":361,"readingTime":366,"recommend":361,"references":361,"seo":371,"sitemap":372,"stem":373,"tags":374,"type":379,"updated":357,"__hash__":380},"content\u002Fposts\u002F2026\u002Fexternal-link-jump-page.md","给 blog-v3 加一个外链跳转确认页",{"type":7,"value":8,"toc":341},"minimark",[9,18,22,33,36,49,57,62,66,69,95,99,109,119,125,131,141,144,147,153,164,179,182,189,209,212,218,221,228,232,238,254,257,263,269,273,276,297,300,318,321,324,338],[10,11,14],"alert",{"title":12,"type":13},"这篇文章对应博客的 \u002Fjump 页面","info",[15,16,17],"p",{},"现在正文里的外链会先进入一个确认页，展示目标域名、连接状态和证书信息，再决定要不要继续。它不是安全边界，只是一道减速带。",[19,20,21],"h2",{"id":21},"起因",[15,23,24,25,32],{},"昨天看到",[26,27,31],"a",{"href":28,"rel":29},"https:\u002F\u002Fusj.cc\u002F20261017134042.html",[30],"nofollow","优世界的这篇文章","，里面提到外链被利用、备案域名被拿去做跳板之类的问题。看完之后有点坐不住。",[15,34,35],{},"我的域名也是备案的。以前总觉得“文章里的链接都是自己放的，能有什么问题”，但实际情况要复杂得多：",[37,38,39,43,46],"ul",{},[40,41,42],"li",{},"旧文章里的链接会越来越多，目标站点可能过期、换域名，甚至被劫持",[40,44,45],{},"转载、友链、评论里的地址，不一定都经过我确认",[40,47,48],{},"读者点出去之后，很难再解释“这个页面不是我的”",[15,50,51,52,56],{},"静态博客没有服务端可以做统一跳转，这确实是个限制。Vercel 有 Edge Function，Cloudflare 有 Worker，但为了一个确认页再引入一套运行时，我暂时不太想折腾。所以先做了一个折中方案：",[53,54,55],"strong",{},"离开之前，多问一句","。",[58,59],"link-card",{"description":60,"link":28,"title":61},"这次让我决定给博客加一道外链确认的起因","优世界 · 关于外链的那些事",[19,63,65],{"id":64},"做法一个页面加一次拦截","做法：一个页面，加一次拦截",[15,67,68],{},"目标很简单：",[70,71,72,79,82,85,92],"ol",{},[40,73,74,75],{},"正文里的站外链接先进入 ",[76,77,78],"code",{"code":78},"\u002Fjump",[40,80,81],{},"页面展示目标域名、完整地址和连接状态",[40,83,84],{},"用户确认后再打开，默认新标签页",[40,86,87,88,91],{},"只接受 ",[76,89,90],{"code":90},"http\u002Fhttps","，拒绝伪协议和带账号密码的 URL",[40,93,94],{},"不额外引入后端，检测接口复用 Nuxt 的 server route",[96,97,98],"h3",{"id":98},"跳转页",[15,100,101,104,105,108],{},[76,102,103],{"code":103},"\u002Fjump?target=...","，",[76,106,107],{"code":107},"target"," 同时支持明文 URL 和 Base64URL。后者主要是避免一些平台或统计工具把参数拆得很难看。",[110,111,117],"pre",{"className":112,"code":114,"language":115,"meta":116},[113],"language-ts","function decodeTarget(raw: string) {\n  const value = raw.trim()\n  if (!value)\n    return ''\n\n  if (\u002F^https?:\\\u002F\\\u002F\u002Fi.test(value))\n    return value\n\n  const normalized = value.replace(\u002F-\u002Fg, '+').replace(\u002F_\u002Fg, '\u002F')\n  const padded = normalized.padEnd(Math.ceil(normalized.length \u002F 4) * 4, '=')\n  const binary = atob(padded)\n  const bytes = Uint8Array.from(binary, char => char.charCodeAt(0))\n  return new TextDecoder().decode(bytes).trim()\n}\n","ts","",[76,118,114],{"__ignoreMap":116},[15,120,121,122,124],{},"校验只认 ",[76,123,90],{"code":90},"，并且不允许 URL 里带用户名密码：",[110,126,129],{"className":127,"code":128,"language":115,"meta":116},[113],"const isValid = computed(() => {\n  const url = parsedTarget.value\n  return !!url\n    && (url.protocol === 'http:' || url.protocol === 'https:')\n    && !url.username\n    && !url.password\n})\n",[76,130,128],{"__ignoreMap":116},[15,132,133,134,137,138,56],{},"页面本身是普通 Vue 页面，关掉默认布局，卡片居中。目标域名单独显示，完整 URL 可以一键复制；继续访问用 ",[76,135,136],{"code":136},"target=\"_blank\"","，并带上 ",[76,139,140],{"code":140},"rel=\"noopener noreferrer nofollow\"",[96,142,143],{"id":143},"正文外链拦截",[15,145,146],{},"没有去改 Markdown 渲染器，而是在客户端加了一个全局 click 监听：",[110,148,151],{"className":149,"code":150,"language":115,"meta":116},[113],"const anchor = (event.target as Element).closest('a')\nif (!anchor?.closest('#main-content'))\n  return\nif (anchor.dataset.noJump !== undefined || anchor.hasAttribute('download'))\n  return\nif (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)\n  return\nif (!isExternal(url))\n  return\n\nconst from = `${location.pathname}${location.search}${location.hash}`\nevent.preventDefault()\nrouter.push(`\u002Fjump?target=${encodeTarget(url.href)}&from=${encodeURIComponent(from)}`)\n",[76,152,150],{"__ignoreMap":116},[15,154,155,156,159,160,163],{},"只拦 ",[76,157,158],{"code":158},"#main-content, .blog-footer"," 里的链接，正文和页脚会进入确认页，侧栏和导航保持原生行为；按住 Ctrl\u002FCmd 或中键仍然按浏览器默认行为打开。需要放行的链接，加一个 ",[76,161,162],{"code":162},"data-no-jump"," 就行。",[15,165,166,167,170,171,174,175,178],{},"返回按钮也踩过一个坑：不要用 ",[76,168,169],{"code":169},"history.back()","。外链如果在新标签页打开，历史栈并不可靠，返回时可能只渲染出页脚。现在会把来源路径放进 ",[76,172,173],{"code":173},"from","，返回时用 ",[76,176,177],{"code":177},"window.location.replace()"," 整页回到来源地址；没有来源时才回首页。",[19,180,181],{"id":181},"顺手加的目标站点检测",[15,183,184,185,188],{},"确认页如果只写一句“这是外链”，信息量其实不够。既然博客在 Vercel 上本来就有 Nitro server route，就顺手做了一个 ",[76,186,187],{"code":187},"\u002Fapi\u002Flink-check","，让页面在打开时自动检测目标站点：",[37,190,191,197,200,203,206],{},[40,192,193,194,196],{},"只允许 ",[76,195,90],{"code":90}," 和 80\u002F443 标准端口",[40,198,199],{},"拒绝 localhost、内网 IP，并在真正连接前再次校验 DNS 解析结果，防止 DNS rebinding",[40,201,202],{},"8 秒超时，最多跟随 4 次重定向",[40,204,205],{},"返回状态码、首字节延迟、HTTPS\u002FHSTS、证书有效期和重定向链",[40,207,208],{},"按来源限流 12 次\u002F分钟，结果缓存 5 分钟，避免被当成探测工具",[15,210,211],{},"页面这边只负责展示：",[110,213,216],{"className":214,"code":215,"language":115,"meta":116},[113],"const data = await $fetch('\u002Fapi\u002Flink-check', {\n  query: { target: target.value },\n  timeout: 12000,\n})\n",[76,217,215],{"__ignoreMap":116},[15,219,220],{},"检测中显示骨架屏，完成后给出“连接安全 \u002F 需要留意 \u002F 存在风险”的状态、延迟、HTTPS 和证书剩余天数；失败时显示具体原因，比如域名解析失败、连接超时或证书过期。",[10,222,225],{"title":223,"type":224},"检测结果不等于内容安全","warning",[15,226,227],{},"它只能说明目标站点能不能连上、证书是否有效、有没有异常跳转，不能判断页面里写了什么。真正的内容风险，还是得靠自己判断。",[19,229,231],{"id":230},"踩到的坑预渲染把-query-吃掉了","踩到的坑：预渲染把 query 吃掉了",[15,233,234,235,237],{},"第一次构建完，直接打开 ",[76,236,103],{"code":103},"，页面显示“链接不可用”；但在站内点进去，hydration 之后又正常。",[15,239,240,241,244,245,104,248,250,251,253],{},"原因是 ",[76,242,243],{"code":243},"nuxt.config.ts"," 里开了 ",[76,246,247],{"code":247},"prerender.crawlLinks",[76,249,78],{"code":78}," 被当成普通页面预渲染成静态 HTML。之后所有带 query 的请求都命中这份没有 ",[76,252,107],{"code":107}," 的静态页，服务端渲染自然拿不到参数。",[15,255,256],{},"修复只有一行：",[110,258,261],{"className":259,"code":260,"language":115,"meta":116},[113],"routeRules: {\n  '\u002Fjump': { prerender: false },\n}\n",[76,262,260],{"__ignoreMap":116},[15,264,265,266,56],{},"这也是做静态博客时很容易忽略的一点：",[53,267,268],{},"只要页面依赖 query，就不能让它进预渲染",[19,270,272],{"id":271},"它能做什么不能做什么","它能做什么，不能做什么",[15,274,275],{},"能做的：",[37,277,278,281,284,291,294],{},[40,279,280],{},"让读者在离开前看到目标域名，减少误点",[40,282,283],{},"统一处理正文里的外链，不用逐篇改 Markdown",[40,285,286,287,290],{},"对 ",[76,288,289],{"code":289},"javascript:","、伪协议、带凭据的 URL 做基础过滤",[40,292,293],{},"展示连接层状态，至少能提前发现域名失效、超时、证书过期",[40,295,296],{},"不额外引入服务，检测接口跟站点一起部署",[15,298,299],{},"不能做的：",[37,301,302,305,308,315],{},[40,303,304],{},"不能阻止用户继续访问，确认页只是提示",[40,306,307],{},"不能判断目标页面的内容是否可信",[40,309,310,311,314],{},"不能替代服务端 302、",[76,312,313],{"code":313},"Referrer-Policy"," 和长期的链接巡检",[40,316,317],{},"JS 被禁用时拦截不生效，链接仍然可以直接打开",[15,319,320],{},"所以它更像一道减速带，而不是安全边界。",[19,322,323],{"id":323},"后面还想做的",[37,325,326,329,332,335],{},[40,327,328],{},"外链白名单：常用站点直接放行，不弹确认",[40,330,331],{},"链接巡检：定期检查文章里的外链状态，失效的标出来",[40,333,334],{},"点击统计：看看哪些外链真的有人点",[40,336,337],{},"真正的 302 中间页：用 Nitro route 在服务端做跳转，顺便控制 Referrer",[15,339,340],{},"现在先这样。静态博客有静态博客的限制，但在限制里把能做的做好，也比什么都不做好。",{"title":116,"searchDepth":342,"depth":342,"links":343},4,[344,346,351,352,353,354],{"id":21,"depth":345,"text":21},2,{"id":64,"depth":345,"text":65,"children":347},[348,350],{"id":98,"depth":349,"text":98},3,{"id":143,"depth":349,"text":143},{"id":181,"depth":345,"text":181},{"id":230,"depth":345,"text":231},{"id":271,"depth":345,"text":272},{"id":323,"depth":345,"text":323},[356],"博客折腾","2026-10-19","静态博客也能在离开之前多问一句。记录 blog-v3 的外链确认页、正文拦截、目标站点检测，以及那个“预渲染吞掉 query”的坑。",false,"md",null,{"slots":363},{},true,"\u002F2026\u002Fexternal-link-jump-page",{"text":367,"minutes":368,"time":369,"words":370},"8 min read",7.485,449100,1497,{"title":5,"description":358},{"loc":365},"posts\u002F2026\u002Fexternal-link-jump-page",[375,376,377,378],"Nuxt","外链安全","blog-v3","Clarity","story","I1jA5h3YEHftRkQM_eStvi2DJjjHdVsddFgk1AXMFB0",[382,361],{"title":383,"path":384,"stem":385,"date":386,"type":379,"children":-1},"blog-v3 统计：Umami 访问趋势","\u002Fumami-analytics-page","posts\u002F2026\u002Fumami-analytics-page","2026-10-16",1792388586213]