外链跳转
This commit is contained in:
@@ -143,6 +143,7 @@ const currentYear = new Date().getFullYear();
|
|||||||
<script is:inline src="https://cdn.jsdmirror.com/npm/@fancyapps/ui@5.0/dist/fancybox/fancybox.umd.js"></script>
|
<script is:inline src="https://cdn.jsdmirror.com/npm/@fancyapps/ui@5.0/dist/fancybox/fancybox.umd.js"></script>
|
||||||
<script is:inline src="/js/random.js"></script>
|
<script is:inline src="/js/random.js"></script>
|
||||||
<script is:inline src="/js/search/typesense-search.js"></script>
|
<script is:inline src="/js/search/typesense-search.js"></script>
|
||||||
|
<script is:inline src="/js/external-links.js"></script>
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
(() => {
|
(() => {
|
||||||
const storageKey = 'site-theme';
|
const storageKey = 'site-theme';
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export const externalLinkWhitelist = [
|
||||||
|
'blog.biss.click',
|
||||||
|
'biss.click',
|
||||||
|
'github.com',
|
||||||
|
'astro.build',
|
||||||
|
'edgeone.ai',
|
||||||
|
'localhost',
|
||||||
|
'127.0.0.1',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const externalLinkWaitSeconds = 10;
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '@/layouts/BaseLayout.astro';
|
||||||
|
import { externalLinkWaitSeconds, externalLinkWhitelist } from '@/lib/external-link-whitelist';
|
||||||
|
|
||||||
|
const whitelist = externalLinkWhitelist.map((domain) => domain.toLowerCase());
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title="外链跳转" description="即将离开本站,请确认访问的外部链接。">
|
||||||
|
<section class="external-redirect" data-external-redirect>
|
||||||
|
<div class="external-redirect-panel">
|
||||||
|
<p class="external-redirect-eyebrow" data-status-label>外部链接</p>
|
||||||
|
<h1 data-title>正在检查目标链接</h1>
|
||||||
|
<p class="external-redirect-copy" data-message>请稍候,正在读取跳转地址。</p>
|
||||||
|
|
||||||
|
<div class="external-redirect-target" data-target-wrap hidden>
|
||||||
|
<span>目标地址</span>
|
||||||
|
<code data-target-url></code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="external-redirect-countdown" data-countdown-wrap hidden>
|
||||||
|
<svg viewBox="0 0 120 120" aria-hidden="true">
|
||||||
|
<circle cx="60" cy="60" r="52"></circle>
|
||||||
|
<circle cx="60" cy="60" r="52" data-countdown-ring></circle>
|
||||||
|
</svg>
|
||||||
|
<strong data-countdown>{externalLinkWaitSeconds}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="external-redirect-actions">
|
||||||
|
<a class="external-redirect-button is-primary" href="/" data-continue hidden>立即访问</a>
|
||||||
|
<a class="external-redirect-button" href="/">返回首页</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script
|
||||||
|
is:inline
|
||||||
|
define:vars={{
|
||||||
|
waitSeconds: externalLinkWaitSeconds,
|
||||||
|
whitelist,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const rawUrl = params.get('url') || params.get('target') || params.get('to') || '';
|
||||||
|
const title = document.querySelector('[data-title]');
|
||||||
|
const message = document.querySelector('[data-message]');
|
||||||
|
const statusLabel = document.querySelector('[data-status-label]');
|
||||||
|
const targetWrap = document.querySelector('[data-target-wrap]');
|
||||||
|
const targetUrl = document.querySelector('[data-target-url]');
|
||||||
|
const countdownWrap = document.querySelector('[data-countdown-wrap]');
|
||||||
|
const countdown = document.querySelector('[data-countdown]');
|
||||||
|
const ring = document.querySelector('[data-countdown-ring]');
|
||||||
|
const continueLink = document.querySelector('[data-continue]');
|
||||||
|
const circumference = 2 * Math.PI * 52;
|
||||||
|
|
||||||
|
function setText(node, text) {
|
||||||
|
if (node) node.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWhitelisted(hostname) {
|
||||||
|
const host = hostname.toLowerCase();
|
||||||
|
return whitelist.some((domain) => {
|
||||||
|
const normalized = String(domain).replace(/^\*\./, '').toLowerCase();
|
||||||
|
return host === normalized || host.endsWith(`.${normalized}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(text) {
|
||||||
|
setText(statusLabel, '无法跳转');
|
||||||
|
setText(title, '链接无效');
|
||||||
|
setText(message, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
let destination;
|
||||||
|
try {
|
||||||
|
destination = new URL(rawUrl);
|
||||||
|
} catch {
|
||||||
|
showError('没有找到有效的目标地址,请返回上一页重新打开链接。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['http:', 'https:'].includes(destination.protocol)) {
|
||||||
|
showError('仅支持跳转到 HTTP 或 HTTPS 链接。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
targetWrap.hidden = false;
|
||||||
|
continueLink.hidden = false;
|
||||||
|
continueLink.href = destination.href;
|
||||||
|
targetUrl.textContent = destination.href;
|
||||||
|
|
||||||
|
if (isWhitelisted(destination.hostname)) {
|
||||||
|
setText(statusLabel, '白名单链接');
|
||||||
|
setText(title, '即将前往可信站点');
|
||||||
|
setText(message, '该域名已在白名单中,将立即为你打开。');
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.location.replace(destination.href);
|
||||||
|
}, 300);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = Number(waitSeconds);
|
||||||
|
setText(statusLabel, '请确认');
|
||||||
|
setText(title, '即将离开本站');
|
||||||
|
setText(message, `目标域名不在白名单中,${remaining} 秒后将自动跳转。`);
|
||||||
|
countdownWrap.hidden = false;
|
||||||
|
countdown.textContent = remaining;
|
||||||
|
|
||||||
|
if (ring) {
|
||||||
|
ring.style.strokeDasharray = `${circumference}`;
|
||||||
|
ring.style.strokeDashoffset = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
remaining -= 1;
|
||||||
|
countdown.textContent = remaining;
|
||||||
|
setText(message, `目标域名不在白名单中,${remaining} 秒后将自动跳转。`);
|
||||||
|
|
||||||
|
if (ring) {
|
||||||
|
ring.style.strokeDashoffset = String(circumference * (1 - remaining / waitSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remaining <= 0) {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
window.location.replace(destination.href);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</BaseLayout>
|
||||||
@@ -2243,6 +2243,165 @@ meting-js {
|
|||||||
border-color: rgba(174, 205, 197, 0.2);
|
border-color: rgba(174, 205, 197, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.external-redirect {
|
||||||
|
width: min(760px, calc(100% - (var(--page-gutter) * 2)));
|
||||||
|
min-height: calc(100vh - 240px);
|
||||||
|
margin: 42px auto 80px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-redirect-panel {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.58);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: clamp(26px, 6vw, 48px);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 15% 0%, rgba(255, 255, 255, 0.74), transparent 36%),
|
||||||
|
linear-gradient(145deg, rgba(255, 255, 255, 0.7), rgba(226, 248, 241, 0.42)),
|
||||||
|
rgba(255, 253, 248, 0.78);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.82),
|
||||||
|
0 24px 64px rgba(44, 55, 63, 0.16);
|
||||||
|
backdrop-filter: blur(18px) saturate(165%);
|
||||||
|
-webkit-backdrop-filter: blur(18px) saturate(165%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-redirect-eyebrow {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-redirect h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: #20242a;
|
||||||
|
font-size: clamp(2rem, 6vw, 3.8rem);
|
||||||
|
line-height: 1.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-redirect-copy {
|
||||||
|
max-width: 620px;
|
||||||
|
margin: 16px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-redirect-target {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 24px;
|
||||||
|
border: 1px solid rgba(15, 118, 110, 0.16);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: rgba(236, 253, 245, 0.64);
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-redirect-target span {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-redirect-target code {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: #33433f;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-redirect-countdown {
|
||||||
|
position: relative;
|
||||||
|
width: 112px;
|
||||||
|
height: 112px;
|
||||||
|
margin-top: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-redirect-countdown svg {
|
||||||
|
width: 112px;
|
||||||
|
height: 112px;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-redirect-countdown circle {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(15, 118, 110, 0.14);
|
||||||
|
stroke-width: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-redirect-countdown circle:last-child {
|
||||||
|
stroke: #3eb8be;
|
||||||
|
stroke-linecap: round;
|
||||||
|
transition: stroke-dashoffset 0.9s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-redirect-countdown strong {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #2f4358;
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-redirect-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-redirect-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
|
border: 1px solid rgba(15, 118, 110, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 16px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 800;
|
||||||
|
background: rgba(236, 253, 245, 0.68);
|
||||||
|
transition:
|
||||||
|
color 0.2s ease,
|
||||||
|
background-color 0.2s ease,
|
||||||
|
transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-redirect-button:hover,
|
||||||
|
.external-redirect-button.is-primary {
|
||||||
|
color: #fff;
|
||||||
|
background: #3eb8be;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .external-redirect-panel {
|
||||||
|
border-color: rgba(174, 205, 197, 0.18);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 14% 0%, rgba(114, 222, 210, 0.1), transparent 34%),
|
||||||
|
linear-gradient(145deg, rgba(28, 43, 47, 0.82), rgba(12, 22, 25, 0.72)),
|
||||||
|
rgba(17, 28, 31, 0.74);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .external-redirect h1,
|
||||||
|
:root[data-theme='dark'] .external-redirect-countdown strong {
|
||||||
|
color: #edf7f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .external-redirect-target,
|
||||||
|
:root[data-theme='dark'] .external-redirect-button {
|
||||||
|
border-color: rgba(114, 222, 210, 0.22);
|
||||||
|
background: rgba(114, 222, 210, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .external-redirect-target code {
|
||||||
|
color: #dce9e6;
|
||||||
|
}
|
||||||
|
|
||||||
.site-footer {
|
.site-footer {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: 64px;
|
margin-top: 64px;
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
(() => {
|
||||||
|
const redirectPath = '/go/';
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
if (window.location.pathname === redirectPath || window.location.pathname === '/go') return;
|
||||||
|
if (urlParams.get('noExternalRedirect') === '1') return;
|
||||||
|
|
||||||
|
function getExternalUrl(anchor) {
|
||||||
|
const href = anchor.getAttribute('href');
|
||||||
|
if (!href || href.startsWith('#')) return null;
|
||||||
|
|
||||||
|
let targetUrl;
|
||||||
|
try {
|
||||||
|
targetUrl = new URL(href, window.location.href);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['http:', 'https:'].includes(targetUrl.protocol)) return null;
|
||||||
|
if (targetUrl.origin === window.location.origin) return null;
|
||||||
|
|
||||||
|
return targetUrl.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
const anchor = event.target.closest?.('a[href]');
|
||||||
|
if (!anchor) return;
|
||||||
|
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||||
|
|
||||||
|
const externalUrl = getExternalUrl(anchor);
|
||||||
|
if (!externalUrl) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const redirectUrl = `${redirectPath}?url=${encodeURIComponent(externalUrl)}`;
|
||||||
|
const target = anchor.getAttribute('target');
|
||||||
|
|
||||||
|
if (target && target !== '_self') {
|
||||||
|
window.open(redirectUrl, target, 'noopener,noreferrer');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = redirectUrl;
|
||||||
|
});
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user