Compare commits
1
Commits
main
...
v1.0.0-rc8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d0786e6db |
No files matched your search
@@ -50,6 +50,36 @@ jobs:
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags "$LDFLAGS" -o lemonipw-freebsd-386
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o lemonipw-freebsd-amd64
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o lemonipw-freebsd-arm64
|
||||
- name: Build middleware-go for Linux
|
||||
working-directory: middleware-go
|
||||
run: |
|
||||
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-386
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-amd64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-arm64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-armv7
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-armv6
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=loong64 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-loong64
|
||||
- name: Build middleware-go for Windows
|
||||
working-directory: middleware-go
|
||||
run: |
|
||||
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=386 go build -ldflags "$LDFLAGS" -o ../middleware-go-windows-386.exe
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-windows-amd64.exe
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-windows-arm64.exe
|
||||
- name: Build middleware-go for macOS
|
||||
working-directory: middleware-go
|
||||
run: |
|
||||
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-darwin-amd64
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-darwin-arm64
|
||||
- name: Build middleware-go for FreeBSD
|
||||
working-directory: middleware-go
|
||||
run: |
|
||||
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags "$LDFLAGS" -o ../middleware-go-freebsd-386
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-freebsd-amd64
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-freebsd-arm64
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
if: github.ref_type == 'tag'
|
||||
@@ -74,3 +104,17 @@ jobs:
|
||||
lemonipw-freebsd-386
|
||||
lemonipw-freebsd-amd64
|
||||
lemonipw-freebsd-arm64
|
||||
middleware-go-linux-386
|
||||
middleware-go-linux-amd64
|
||||
middleware-go-linux-arm64
|
||||
middleware-go-linux-armv7
|
||||
middleware-go-linux-armv6
|
||||
middleware-go-linux-loong64
|
||||
middleware-go-windows-386.exe
|
||||
middleware-go-windows-amd64.exe
|
||||
middleware-go-windows-arm64.exe
|
||||
middleware-go-darwin-amd64
|
||||
middleware-go-darwin-arm64
|
||||
middleware-go-freebsd-386
|
||||
middleware-go-freebsd-amd64
|
||||
middleware-go-freebsd-arm64
|
||||
+2
-1
@@ -15,4 +15,5 @@ lemon-ipw
|
||||
*.tmp
|
||||
*.log
|
||||
node_modules/
|
||||
setting.json
|
||||
setting.json
|
||||
.workbuddy/
|
||||
+151
-91
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount, ref } from 'vue';
|
||||
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
|
||||
import { useDark, useToggle } from '@vueuse/core';
|
||||
import { Moon, Sunny, Expand } from '@element-plus/icons-vue';
|
||||
import { config } from '../config/index';
|
||||
@@ -14,8 +14,88 @@ const toggleDark = useToggle(isDark);
|
||||
function cleanChineseCharacters(str:string) {
|
||||
return str.replace(/[\u4e00-\u9fa5]/g, '');
|
||||
}
|
||||
let umamiScript: HTMLScriptElement | null = null
|
||||
|
||||
// ==================== 响应式折叠菜单 ====================
|
||||
// 映射表:菜单按显示顺序排在一个数组里,每项自带 minWidth(px)折叠阈值。
|
||||
// 规则:视口宽度 >= minWidth 显示,低于则折叠进「更多」子菜单。
|
||||
// 断点从右往左递减(最右侧 index 9 先折叠),数值可按需调整。
|
||||
// divider 也带 minWidth(取其后第一项的值):所在段全部折叠时,分隔线一并隐藏。
|
||||
interface NavItem {
|
||||
kind: 'item'
|
||||
index: string
|
||||
label: string
|
||||
to: string
|
||||
minWidth: number
|
||||
}
|
||||
interface NavGroup {
|
||||
kind: 'group'
|
||||
index: string
|
||||
label: string
|
||||
minWidth: number
|
||||
children: { index: string; label: string; to: string }[]
|
||||
}
|
||||
interface NavDivider {
|
||||
kind: 'divider'
|
||||
minWidth: number
|
||||
}
|
||||
type MenuEntry = NavItem | NavGroup | NavDivider
|
||||
|
||||
const menu: MenuEntry[] = [
|
||||
{ kind: 'item', index: '1', label: 'IPv6 网站检测', to: '/ipv6webcheck', minWidth: 950 },
|
||||
{ kind: 'item', index: '2', label: 'IPv6/IPv4 地址查询', to: '/location', minWidth: 990 },
|
||||
{ kind: 'item', index: '3', label: 'IPv6 TCPing测试', to: '/ipv6tcping', minWidth: 1030 },
|
||||
{ kind: 'divider', minWidth: 1080 }, // 与 index 4 相同:4-6 段全折叠时隐藏
|
||||
{ kind: 'item', index: '4', label: 'IPv6 DNS解析', to: '/dns', minWidth: 1080 },
|
||||
{ kind: 'item', index: '5', label: 'IPv6 SSL检查', to: '/ssl', minWidth: 1130 },
|
||||
{ kind: 'item', index: '6', label: 'IPv6 网站测速', to: '/ipv6speedtest', minWidth: 1180 },
|
||||
{ kind: 'divider', minWidth: 1240 }, // 与 index 7 相同:7-9 段全折叠时隐藏
|
||||
{
|
||||
kind: 'group', index: '7', label: 'IPv4工具箱', minWidth: 1240,
|
||||
children: [
|
||||
{ index: '7-0', label: 'IPv4 网站测速', to: '/speedtest' },
|
||||
{ index: '7-1', label: 'IPv4 TCPing测试', to: '/tcping' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'group', index: '8', label: '其他工具', minWidth: 1300,
|
||||
children: [
|
||||
{ index: '8-0', label: '网站截图', to: '/screenshot' },
|
||||
{ index: '8-1', label: 'Whois查询', to: '/whois' },
|
||||
{ index: '8-3', label: 'ASN查询', to: '/asn' },
|
||||
{ index: '8-4', label: 'DNSSEC验证', to: '/dnssec' },
|
||||
],
|
||||
},
|
||||
{ kind: 'item', index: '9', label: '文档', to: '/doc', minWidth: 1360 },
|
||||
]
|
||||
|
||||
// SSR 阶段无 window,默认按宽屏输出全部菜单项;onMounted 后更新为真实视口宽度
|
||||
const viewportWidth = ref(1920)
|
||||
|
||||
// 可见项(含分隔线):视口宽度达标即显示
|
||||
const visible = computed<MenuEntry[]>(() =>
|
||||
menu.filter((entry) => viewportWidth.value >= entry.minWidth),
|
||||
)
|
||||
// 折叠项(不含分隔线):视口宽度不足即收进「更多」。
|
||||
// 用类型谓词收窄为 NavItem | NavGroup,模板里才能安全访问 entry.index
|
||||
const hidden = computed<Array<NavItem | NavGroup>>(
|
||||
() => menu.filter((entry): entry is NavItem | NavGroup =>
|
||||
entry.kind !== 'divider' && viewportWidth.value < entry.minWidth),
|
||||
)
|
||||
|
||||
// 实时同步视口宽度(resize 触发,驱动折叠)
|
||||
function updateViewport() {
|
||||
viewportWidth.value = window.innerWidth
|
||||
}
|
||||
let umamiScript: HTMLScriptElement | null = null
|
||||
useHead({
|
||||
meta: config.noindex
|
||||
? [
|
||||
{ name: 'robots', content: 'noindex, nofollow' },
|
||||
{ name: 'googlebot', content: 'noindex, nofollow' },
|
||||
{ name: 'bingbot', content: 'noindex, nofollow' },
|
||||
]
|
||||
: [],
|
||||
});
|
||||
onMounted(() => {
|
||||
mediaQueryList = window.matchMedia('(max-width: 768px)');
|
||||
isNarrow.value = mediaQueryList.matches;
|
||||
@@ -26,8 +106,12 @@ onMounted(() => {
|
||||
|
||||
mediaQueryList.addEventListener('change', handler);
|
||||
|
||||
updateViewport();
|
||||
window.addEventListener('resize', updateViewport);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
mediaQueryList?.removeEventListener('change', handler);
|
||||
window.removeEventListener('resize', updateViewport);
|
||||
});
|
||||
|
||||
if (!umamiScript && config.umamiScriptUrl) {
|
||||
@@ -43,26 +127,26 @@ onMounted(() => {
|
||||
<template>
|
||||
|
||||
<el-drawer v-if="isNarrow" v-model="drawer" direction="ltr" style="height: 100%;" size="50%">
|
||||
<router-link to="/ipv6webcheck" style="font-size: 1em;">
|
||||
<p style="display: inline-block; margin-left: 10px">IPv6 网站检测</p>
|
||||
<router-link to="/ipv6webcheck">
|
||||
<p class="menu-item-text">IPv6 网站检测</p>
|
||||
</router-link>
|
||||
<router-link to="/location" style="font-size: 1em;">
|
||||
<p style="display: inline-block; margin-left: 10px">IPv6/IPv4 地址查询</p>
|
||||
<router-link to="/location">
|
||||
<p class="menu-item-text">IPv6/IPv4 地址查询</p>
|
||||
</router-link>
|
||||
<router-link to="/ipv6tcping" style="font-size: 1em;">
|
||||
<p style="display: inline-block; margin-left: 10px">IPv6 TCPing</p>
|
||||
<router-link to="/ipv6tcping">
|
||||
<p class="menu-item-text">IPv6 TCPing</p>
|
||||
</router-link>
|
||||
<router-link to="/dns" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">IPv6 DNS解析</p></router-link>
|
||||
<router-link to="/ssl" style="font-size: 1em;">
|
||||
<p style="display: inline-block; margin-left: 10px">IPv6 SSL检查</p>
|
||||
<router-link to="/dns"><p class="menu-item-text">IPv6 DNS解析</p></router-link>
|
||||
<router-link to="/ssl">
|
||||
<p class="menu-item-text">IPv6 SSL检查</p>
|
||||
</router-link>
|
||||
<a href="/ipv6speedtest" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">IPv6 网站测速</p></a>
|
||||
<a href="/speedtest" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">IPv4 网站测速</p></a>
|
||||
<a href="/tcping" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">IPv4 TCPing</p></a>
|
||||
<a href="/screenshot" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">网站截图</p></a>
|
||||
<a href="/whois" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">Whois查询</p></a>
|
||||
<a href="/asn" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">ASN查询</p></a>
|
||||
<a href="/dnssec" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">DNSSEC验证</p></a>
|
||||
<a href="/ipv6speedtest"><p class="menu-item-text">IPv6 网站测速</p></a>
|
||||
<a href="/speedtest"><p class="menu-item-text">IPv4 网站测速</p></a>
|
||||
<a href="/tcping"><p class="menu-item-text">IPv4 TCPing</p></a>
|
||||
<a href="/screenshot"><p class="menu-item-text">网站截图</p></a>
|
||||
<a href="/whois"><p class="menu-item-text">Whois查询</p></a>
|
||||
<a href="/asn"><p class="menu-item-text">ASN查询</p></a>
|
||||
<a href="/dnssec"><p class="menu-item-text">DNSSEC验证</p></a>
|
||||
</el-drawer>
|
||||
<el-menu
|
||||
mode="horizontal"
|
||||
@@ -77,61 +161,35 @@ onMounted(() => {
|
||||
</router-link>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="1" v-if="!isNarrow">
|
||||
<router-link to="/ipv6webcheck" style="font-size: 1em;">
|
||||
<p style="display: inline-block; margin-left: 10px">IPv6 网站检测</p>
|
||||
</router-link>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="2" v-if="!isNarrow">
|
||||
<router-link to="/location">
|
||||
<p style="display: inline-block; margin-left: 10px">IPv6/IPv4 地址查询</p>
|
||||
</router-link>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="3" v-if="!isNarrow">
|
||||
<router-link to="/ipv6tcping" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">IPv6 TCPing测试</p></router-link>
|
||||
</el-menu-item>
|
||||
|
||||
<el-divider style="margin-top: 20px;height: 1.2em;" direction="vertical" v-if="!isNarrow"/>
|
||||
|
||||
<el-menu-item index="4" v-if="!isNarrow">
|
||||
<router-link to="/dns" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">IPv6 DNS解析</p></router-link>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="5" v-if="!isNarrow">
|
||||
<router-link to="/ssl">
|
||||
<p style="display: inline-block; margin-left: 10px">IPv6 SSL检查</p>
|
||||
</router-link>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="6" v-if="!isNarrow">
|
||||
<router-link to="/ipv6speedtest" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">IPv6 网站测速</p></router-link>
|
||||
</el-menu-item>
|
||||
<el-divider style="margin-top: 20px;height: 1.2em;" direction="vertical" v-if="!isNarrow"/>
|
||||
<el-sub-menu index="7" v-if="!isNarrow">
|
||||
<template #title>IPv4工具箱</template>
|
||||
<el-menu-item index="7-0">
|
||||
<router-link to="/speedtest" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">IPv4 网站测速</p></router-link>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="7-1">
|
||||
<router-link to="/tcping" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">IPv4 TCPing测试</p></router-link>
|
||||
<template v-if="!isNarrow">
|
||||
<!-- 可见菜单项:按映射表顺序渲染 item / group / divider -->
|
||||
<template v-for="entry in visible" :key="entry.kind === 'divider' ? 'div-' + entry.minWidth : entry.index">
|
||||
<el-menu-item v-if="entry.kind === 'item'" :index="entry.index">
|
||||
<router-link :to="entry.to"><p class="menu-item-text">{{ entry.label }}</p></router-link>
|
||||
</el-menu-item>
|
||||
<el-sub-menu v-else-if="entry.kind === 'group'" :index="entry.index">
|
||||
<template #title>{{ entry.label }}</template>
|
||||
<el-menu-item v-for="child in entry.children" :key="child.index" :index="child.index">
|
||||
<router-link :to="child.to"><p class="menu-item-text">{{ child.label }}</p></router-link>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-divider v-else style="margin-top: 20px;height: 1.2em;" direction="vertical"/>
|
||||
</template>
|
||||
<!-- 折叠项:统一收进「更多」子菜单(分组用 el-menu-item-group 保留组名) -->
|
||||
<el-sub-menu v-if="hidden.length" index="more">
|
||||
<template #title>更多</template>
|
||||
<template v-for="entry in hidden" :key="entry.index">
|
||||
<el-menu-item v-if="entry.kind === 'item'" :index="'m-' + entry.index">
|
||||
<router-link :to="entry.to"><p class="menu-item-text">{{ entry.label }}</p></router-link>
|
||||
</el-menu-item>
|
||||
<el-menu-item-group v-else :title="entry.label">
|
||||
<el-menu-item v-for="child in entry.children" :key="child.index" :index="'m-' + child.index">
|
||||
<router-link :to="child.to"><p class="menu-item-text">{{ child.label }}</p></router-link>
|
||||
</el-menu-item>
|
||||
</el-menu-item-group>
|
||||
</template>
|
||||
</el-sub-menu>
|
||||
<el-sub-menu index="8" v-if="!isNarrow">
|
||||
<template #title>其他工具</template>
|
||||
<el-menu-item index="8-0">
|
||||
<router-link to="/screenshot" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">网站截图</p></router-link>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="8-1">
|
||||
<router-link to="/whois" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">Whois查询</p></router-link>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="8-3">
|
||||
<router-link to="/asn" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">ASN查询</p></router-link>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="8-4">
|
||||
<router-link to="/dnssec" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">DNSSEC验证</p></router-link>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item index="9" v-if="!isNarrow">
|
||||
<router-link to="/doc" style="font-size: 1em;"><p style="display: inline-block; margin-left: 10px">文档</p></router-link>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
<el-menu-item index="10">
|
||||
<ClientOnly>
|
||||
<el-icon @click="toggleDark()" v-if="isDark" style="cursor: pointer;"><Moon style="height: 20px; width: 20px;"/></el-icon>
|
||||
@@ -170,8 +228,10 @@ onMounted(() => {
|
||||
</template>
|
||||
<style scoped>
|
||||
@import "~/style.css";
|
||||
.el-menu--horizontal > .el-menu-item:nth-child(1) {
|
||||
margin-right: auto;
|
||||
/* 菜单项统一文本样式(替代原内联 style="display: inline-block; margin-left: 10px") */
|
||||
.menu-item-text {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
}
|
||||
:deep(.shiki span) {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Monaco', 'Courier New', monospace !important;
|
||||
@@ -230,24 +290,24 @@ html.dark {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 防止窄屏设备在 Vue 水合前出现宽屏布局闪烁 */
|
||||
html.is-narrow .el-drawer__container {
|
||||
display: none !important;
|
||||
}
|
||||
html.is-narrow .el-menu--horizontal > .el-divider {
|
||||
display: none !important;
|
||||
}
|
||||
html.is-narrow .el-menu--horizontal > .el-menu-item[index="1"],
|
||||
html.is-narrow .el-menu--horizontal > .el-menu-item[index="2"],
|
||||
html.is-narrow .el-menu--horizontal > .el-menu-item[index="3"],
|
||||
html.is-narrow .el-menu--horizontal > .el-menu-item[index="4"],
|
||||
html.is-narrow .el-menu--horizontal > .el-menu-item[index="5"],
|
||||
html.is-narrow .el-menu--horizontal > .el-menu-item[index="6"],
|
||||
html.is-narrow .el-menu--horizontal > .el-sub-menu[index="7"]
|
||||
html.is-narrow .el-menu--horizontal > .el-sub-menu[index="8"]
|
||||
html.is-narrow .el-menu--horizontal > .el-sub-menu[index="9"]
|
||||
html.is-narrow .el-menu--horizontal > .el-sub-menu[index="10"] {
|
||||
display: none !important;
|
||||
/* 窄屏(≤768px,与 script 中 isNarrow 的 matchMedia 断点一致):
|
||||
在水合前由 CSS 兜底隐藏宽屏菜单项,避免窄屏设备闪现宽屏布局 */
|
||||
@media (max-width: 768px) {
|
||||
.el-menu--horizontal > .el-divider {
|
||||
display: none !important;
|
||||
}
|
||||
.el-menu--horizontal > .el-menu-item[index="1"],
|
||||
.el-menu--horizontal > .el-menu-item[index="2"],
|
||||
.el-menu--horizontal > .el-menu-item[index="3"],
|
||||
.el-menu--horizontal > .el-menu-item[index="4"],
|
||||
.el-menu--horizontal > .el-menu-item[index="5"],
|
||||
.el-menu--horizontal > .el-menu-item[index="6"],
|
||||
.el-menu--horizontal > .el-sub-menu[index="7"],
|
||||
.el-menu--horizontal > .el-sub-menu[index="8"],
|
||||
.el-menu--horizontal > .el-menu-item[index="9"],
|
||||
.el-menu--horizontal > .el-sub-menu[index="more"] {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
.el-menu--horizontal {
|
||||
--el-menu-hover-bg-color: transparent !important;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { ref, onMounted, computed, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index'
|
||||
@@ -59,7 +60,7 @@ const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
|
||||
|
||||
const { data: asnData, error: asnError, execute: executeASN } = useFetch<ASNResult>(() => '/middleware/' + backendID.value + '/asn/' + tmpIP.value, {
|
||||
const { data: asnData, error: asnError, execute: executeASN } = useMiddlewareFetch<ASNResult>(() => '/middleware/' + backendID.value + '/asn/' + tmpIP.value, {
|
||||
key: 'asn-fetch',
|
||||
immediate: false,
|
||||
watch: false,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { isIPv6 } from 'is-ip';
|
||||
import { formatTime } from '../../utils/tools';
|
||||
const route = useRoute()
|
||||
@@ -73,7 +74,7 @@ const recordTypes = [
|
||||
|
||||
const dnsServerFetches = config.NSLookup.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/dns/" + recordType.value + "/" + domain.value);
|
||||
const { data, error, execute } = useFetch(url, {
|
||||
const { data, error, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index'
|
||||
@@ -72,7 +73,7 @@ const apiList = config.NSLookup
|
||||
|
||||
const dnssecFetches = apiList.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/dnssec/" + tmpDomain.value)
|
||||
const { data, error, execute } = useFetch<DNSSECResult>(url, {
|
||||
const { data, error, execute } = useMiddlewareFetch<DNSSECResult>(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted, computed, nextTick, watch } from 'vue';
|
||||
import { isIPv6 } from 'is-ip';
|
||||
import {config} from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
|
||||
import { useRoute } from 'vue-router'
|
||||
import { highlightCode } from '../../utils/shiki'
|
||||
@@ -50,7 +51,7 @@ const IPLocation = ref<IPLocationType>({});
|
||||
const UserIP = ref('');
|
||||
const locationUrl = computed(() => "/middleware/" + backendID.value + "/location/" + ipAddress.value);
|
||||
|
||||
const { data: locationData, error: locationError, execute: executeLocation } = useFetch<IPLocationType>(locationUrl, {
|
||||
const { data: locationData, error: locationError, execute: executeLocation } = useMiddlewareFetch<IPLocationType>(locationUrl, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { extractHost, formatTime, formatSpeed, formatSize, getStatusCodeClass } from '../../utils/tools';
|
||||
|
||||
|
||||
@@ -69,7 +70,7 @@ const serverResults = ref<ServerResult[]>([])
|
||||
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
|
||||
const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + domain.value);
|
||||
const { data, error: fetchError, execute } = useFetch(url, {
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
@@ -79,7 +80,7 @@ const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
|
||||
/** IPv6 专用服务器:仅支持 IPv6 的节点 */
|
||||
const ipv6Fetches = config.SpeedTest.IPv6.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + domain.value);
|
||||
const { data, error: fetchError, execute } = useFetch(url, {
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { isIPv6 } from 'is-ip';
|
||||
import { extractHost } from '../../utils/tools';
|
||||
const route = useRoute()
|
||||
@@ -84,7 +85,7 @@ const allServers = [
|
||||
|
||||
const tcpingFetches = allServers.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/tcping/" + extractHost(tmpDomain.value) + "?port=" + port.value);
|
||||
const { data, error: fetchError, execute } = useFetch<TCPingResponse>(url, {
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch<TCPingResponse>(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted, computed, nextTick, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { CircleCheckFilled, CircleCloseFilled,InfoFilled,Position } from '@element-plus/icons-vue';
|
||||
import { extractHost, getStatusCodeClass, formatTime, formatSize, formatSpeed } from '../../utils/tools';
|
||||
|
||||
@@ -72,7 +73,7 @@ const error = ref('')
|
||||
const result = ref<PerformanceCheckResponse | null>(null)
|
||||
const checkUrl = computed(() => "/middleware/" + backendID.value + "/detail/" + testDomain.value);
|
||||
|
||||
const { data: checkData, error: checkError, execute: executeCheck } = useFetch<PerformanceCheckResponse>(checkUrl, {
|
||||
const { data: checkData, error: checkError, execute: executeCheck } = useMiddlewareFetch<PerformanceCheckResponse>(checkUrl, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted, computed, nextTick, watch } from 'vue';
|
||||
import { isIPv6 } from 'is-ip';
|
||||
import {config} from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
|
||||
import { useRoute } from 'vue-router'
|
||||
import { highlightCode } from '../../utils/shiki'
|
||||
@@ -75,7 +76,7 @@ const UserIP = ref('');
|
||||
const error = ref('')
|
||||
const locationUrl = computed(() => "/middleware/" + backendID.value + "/location/" + ipAddress.value);
|
||||
|
||||
const { data: locationData, error: locationError, execute: executeLocation } = useFetch<IPLocationType>(locationUrl, {
|
||||
const { data: locationData, error: locationError, execute: executeLocation } = useMiddlewareFetch<IPLocationType>(locationUrl, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { extractHost, formatTime, formatSpeed, formatSize, getStatusCodeClass } from '../../utils/tools';
|
||||
|
||||
|
||||
@@ -69,7 +70,7 @@ const serverResults = ref<ServerResult[]>([])
|
||||
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
|
||||
const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + domain.value);
|
||||
const { data, error: fetchError, execute } = useFetch(url, {
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
@@ -79,7 +80,7 @@ const dualStackFetches = config.SpeedTest.DualStack.map((server) => {
|
||||
/** IPv4 专用服务器:仅支持 IPv4 的节点 */
|
||||
const ipv4Fetches = config.SpeedTest.IPv4.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + domain.value);
|
||||
const { data, error: fetchError, execute } = useFetch(url, {
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, onMounted, computed, nextTick, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { CircleCheckFilled, CircleCloseFilled,InfoFilled } from '@element-plus/icons-vue';
|
||||
import { config } from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { extractHost, getStatusCodeClass, formatTime, formatSpeed } from '../../utils/tools';
|
||||
const route = useRoute()
|
||||
|
||||
@@ -74,7 +75,7 @@ const error = ref('')
|
||||
const result = ref<SSLCheckResponse | null>(null)
|
||||
const sslUrl = computed(() => "/middleware/" + backendID.value + "/ssl/" + testDomain.value);
|
||||
|
||||
const { data: sslData, error: sslError, execute: executeSSL } = useFetch<SSLCheckResponse>(sslUrl, {
|
||||
const { data: sslData, error: sslError, execute: executeSSL } = useMiddlewareFetch<SSLCheckResponse>(sslUrl, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { extractHost } from '../../utils/tools';
|
||||
|
||||
const route = useRoute()
|
||||
@@ -83,7 +84,7 @@ const allServers = [
|
||||
|
||||
const tcpingFetches = allServers.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/tcping/" + extractHost(tmpDomain.value) + "?port=" + port.value);
|
||||
const { data, error: fetchError, execute } = useFetch<TCPingResponse>(url, {
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch<TCPingResponse>(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { ref, onMounted, computed, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index'
|
||||
@@ -68,7 +69,7 @@ const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
const whoisUrl = computed(() => '/middleware/' + backendID.value + '/whois/' + domain.value)
|
||||
|
||||
const { data: whoisData, error: whoisError, execute: executeWhois } = useFetch<any>(whoisUrl, {
|
||||
const { data: whoisData, error: whoisError, execute: executeWhois } = useMiddlewareFetch<any>(whoisUrl, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
})
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
*/
|
||||
const config = {
|
||||
siteUrl: "https://ipw.wsmdn.top/",
|
||||
// 外部独立中间件服务列表(base URL)。
|
||||
// 与前端自带中间件(server/routes/middleware/[...slug].get.ts 本地转发)同级,
|
||||
// 前端 /middleware/* 请求会依次尝试这些节点(出错重试下一个),
|
||||
// 前端自带中间件始终放在候选列表最后一位兜底。
|
||||
// 未配置或为空数组时,直接使用前端自带中间件。
|
||||
Middleware: <string[]>[
|
||||
"http://127.0.0.1:8091/",
|
||||
// "https://middleware-2.wsmdn.top/",
|
||||
],
|
||||
// Umami 统计
|
||||
umamiHost: "https://umami.wsmdn.top/",
|
||||
umamiScriptUrl: "https://umami.wsmdn.top/zako.js",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineEventHandler, getQuery, createError } from 'h3'
|
||||
import { defineEventHandler, getQuery, createError, setResponseStatus } from 'h3'
|
||||
import { config } from '../../../config/index'
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slugString: string = event.context.params?.slug as any
|
||||
@@ -100,14 +100,18 @@ export default defineEventHandler(async (event) => {
|
||||
data = await $fetch(`${apiBaseUrl}v1/${apiType}/${raw}`, {
|
||||
method: 'GET',
|
||||
headers: authHeaders,
|
||||
}).catch((error) => {
|
||||
}).catch((error: any) => {
|
||||
console.error(`Error fetching from ${apiBaseUrl}:`, error)
|
||||
throw createError({ statusCode: 500, statusMessage: 'Backend error '+error })
|
||||
})|| {}
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'Backend error' })
|
||||
}
|
||||
|
||||
const errStatus = error?.status ?? error?.statusCode
|
||||
if (errStatus) {
|
||||
// 上游返回的错误响应(4xx/5xx),直接转发其状态码与错误体,不包装成 500
|
||||
setResponseStatus(event, errStatus)
|
||||
return error.data ?? {}
|
||||
}
|
||||
// 网络层错误(无法连接上游)
|
||||
setResponseStatus(event, 502)
|
||||
return { statusCode: 502, statusMessage: 'Backend unreachable' }
|
||||
})
|
||||
return data
|
||||
}else if (apiType === 'tcping' || apiType === 'udping' || apiType === 'speed') {
|
||||
let apiBaseUrls:Array<{ id: string, url: string ,label: string}> = []
|
||||
@@ -146,17 +150,21 @@ export default defineEventHandler(async (event) => {
|
||||
data = await $fetch(`${apiBaseUrl}v1/${apiType}/${raw}${queryString ? '?' + queryString : ''}`, {
|
||||
method: 'GET',
|
||||
headers: authHeaders,
|
||||
}).catch((error) => {
|
||||
}).catch((error: any) => {
|
||||
console.error(`Error fetching from ${apiBaseUrl}:`, error)
|
||||
throw createError({ statusCode: 500, statusMessage: 'Backend error '+error })
|
||||
})|| {}
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'Backend error' })
|
||||
}
|
||||
|
||||
const errStatus = error?.status ?? error?.statusCode
|
||||
if (errStatus) {
|
||||
// 上游返回的错误响应(4xx/5xx),直接转发其状态码与错误体,不包装成 500
|
||||
setResponseStatus(event, errStatus)
|
||||
return error.data ?? {}
|
||||
}
|
||||
// 网络层错误(无法连接上游)
|
||||
setResponseStatus(event, 502)
|
||||
return { statusCode: 502, statusMessage: 'Backend unreachable' }
|
||||
})
|
||||
return data
|
||||
|
||||
|
||||
}
|
||||
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ref, computed, watch, toValue, type MaybeRefOrGetter } from 'vue'
|
||||
import { useFetch } from 'nuxt/app'
|
||||
import { config } from '../config/index'
|
||||
|
||||
// 构建中间件候选 URL 数组:
|
||||
// config.Middleware 外部节点(base URL + 相对路径)在前,
|
||||
// 前端自带中间件(传过来的相对路径本身,如 /middleware/...,由 useFetch 自行解析、走 Nuxt Server 的 [...slug].get.ts)放最后一位。
|
||||
// 未配置 Middleware 数组或为空时,直接返回 [path](只用前端自带中间件)。
|
||||
function buildCandidates(path: string): string[] {
|
||||
const external: string[] = Array.isArray(config.Middleware)
|
||||
? config.Middleware.filter((u): u is string => !!u)
|
||||
: []
|
||||
if (external.length === 0) return [path]
|
||||
return [...external.map(u => u.replace(/\/$/, '') + path), path]
|
||||
}
|
||||
|
||||
// 获取错误的状态码:优先 status(ofetch 2.x),兼容 statusCode(旧版 ofetch)
|
||||
function getErrorStatus(e: any): number | undefined {
|
||||
return e?.status ?? e?.statusCode
|
||||
}
|
||||
|
||||
// 判断是否为"节点无法连接"错误(网络层错误:无 HTTP 状态码)
|
||||
function isConnectionError(e: any): boolean {
|
||||
return !!e && typeof getErrorStatus(e) !== 'number'
|
||||
}
|
||||
|
||||
// useMiddlewareFetch:在 Vue 页面中调用中间件(替代直接 useFetch('/middleware/...'))。
|
||||
// 外部中间件与前端自带中间件同级:依次尝试 config.Middleware 中的外部节点,
|
||||
// 仅当"节点无法连接"或上游返回 401 时重试下一个候选(最后一位兜底为前端自带中间件);
|
||||
// 其余状态码错误不重试,直接返回给页面展示。
|
||||
export function useMiddlewareFetch<T = any>(url: MaybeRefOrGetter<string>, options?: any) {
|
||||
const path = computed(() => toValue(url) || '')
|
||||
const candidates = computed(() => buildCandidates(path.value))
|
||||
// 当前尝试的候选下标
|
||||
const idx = ref(0)
|
||||
const currentUrl = computed(() => candidates.value[idx.value] ?? path.value)
|
||||
|
||||
const result = useFetch<T>(currentUrl, options)
|
||||
|
||||
// 仅"节点无法连接"或 401 时切换到下一个候选并重新请求
|
||||
watch(() => result.error.value, (err) => {
|
||||
if (!err) return
|
||||
if (idx.value >= candidates.value.length - 1) return
|
||||
if (!isConnectionError(err) && getErrorStatus(err) !== 401) return
|
||||
idx.value++
|
||||
result.execute()
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
module github.com/ipw-cn/middleware-go
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||
github.com/gofiber/fiber/v3 v3.4.0 // indirect
|
||||
github.com/gofiber/schema v1.8.0 // indirect
|
||||
github.com/gofiber/utils/v2 v2.1.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.19.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.72.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/gofiber/fiber/v3 v3.4.0 h1:F0aND4vwZF7dR7cbvSwFQQEpBU902XHKWxrLsFBkVqw=
|
||||
github.com/gofiber/fiber/v3 v3.4.0/go.mod h1:nAhJfdxUIJJph2tPWPmqWf8QDIN2iiqQiQf3lENZpdk=
|
||||
github.com/gofiber/schema v1.8.0 h1:NGsC9toPHmj8Xg4KpznuXBzNmHG6V5YV0tXKpKMcmis=
|
||||
github.com/gofiber/schema v1.8.0/go.mod h1:lmbXPQ8hvzXSLkdS2DS7pb4kpunC2Roh7Sj3HMjGfzA=
|
||||
github.com/gofiber/utils/v2 v2.1.1 h1:kGnoGjwEnFW6w0x45W+kLlmMJvqBGkuUA4oMWKn/T/I=
|
||||
github.com/gofiber/utils/v2 v2.1.1/go.mod h1:DdOgEVwQTi8cou/AKWPqhXOR4fHGRVhA/rEWL3IXG7Q=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M=
|
||||
github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
@@ -0,0 +1,414 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/cors"
|
||||
)
|
||||
|
||||
// ==================== 全局变量 ====================
|
||||
|
||||
// apiInfo 对应 setting.json apiBaseUrls 等数组中的 { label, id, url } 配置项
|
||||
type apiInfo struct {
|
||||
Label string `json:"label"`
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// stackConfig 对应 setting.json TCPing / SpeedTest
|
||||
type stackConfig struct {
|
||||
DualStack []apiInfo `json:"DualStack"`
|
||||
IPv4 []apiInfo `json:"IPv4"`
|
||||
IPv6 []apiInfo `json:"IPv6"`
|
||||
}
|
||||
|
||||
// middlewareConfig 仅用于解析 setting.json(键名与前端 config/index.ts 保持一致)
|
||||
type middlewareConfig struct {
|
||||
Port stringOrNumber `json:"port"`
|
||||
HTTPTimeoutSeconds int `json:"httpTimeoutSeconds"`
|
||||
APIBaseURLs []apiInfo `json:"apiBaseUrls"`
|
||||
IPLocationAPIs []apiInfo `json:"IPLocationAPIs"`
|
||||
TCPing stackConfig `json:"TCPing"`
|
||||
SpeedTest stackConfig `json:"SpeedTest"`
|
||||
NSLookup []apiInfo `json:"NSLookup"`
|
||||
APIKeys map[string]string `json:"apiKeys"`
|
||||
}
|
||||
|
||||
// stringOrNumber 兼容 JSON 中的字符串与数字(如 "8080" 或 8080)
|
||||
type stringOrNumber string
|
||||
|
||||
// 全局配置变量,由 readConfig 从 setting.json 填充
|
||||
var (
|
||||
VERSION string // 版本号(构建时由 ldflags 注入)
|
||||
COMMIT string // 提交哈希(构建时由 ldflags 注入)
|
||||
BUILD_TIME string // 构建时间(构建时由 ldflags 注入)
|
||||
PORT string // 监听端口
|
||||
HTTP_TIMEOUT int // 上游请求超时(秒)
|
||||
API_BASE_URLS []apiInfo // whois/ssl/detail 上游列表
|
||||
IP_LOCATION_APIS []apiInfo // location/asn 上游列表
|
||||
TCPING stackConfig // tcping 上游列表(DualStack/IPv4/IPv6)
|
||||
SPEED_TEST stackConfig // speed 上游列表(DualStack/IPv4/IPv6)
|
||||
NS_LOOKUP []apiInfo // dns/dnssec 上游列表
|
||||
API_KEYS map[string]string // backendID → token
|
||||
CONFIG_SOURCE string // 配置文件路径
|
||||
)
|
||||
|
||||
// HTTP_CLIENT 用于转发上游请求(等价于 TS 中的 $fetch),超时由 HTTP_TIMEOUT 决定
|
||||
// HTTP_CLIENT forwards requests to upstream backends (equivalent to $fetch in the TS version).
|
||||
var HTTP_CLIENT = &http.Client{}
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
func (s *stringOrNumber) UnmarshalJSON(data []byte) error {
|
||||
var v any
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
*s = stringOrNumber(t)
|
||||
case float64:
|
||||
*s = stringOrNumber(strconv.FormatFloat(t, 'f', -1, 64))
|
||||
default:
|
||||
return fmt.Errorf("cannot unmarshal %T as string-or-number", v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// badRequest 返回 400 错误,响应格式与 h3 createError 一致
|
||||
func badRequest(c fiber.Ctx, msg string) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"statusCode": fiber.StatusBadRequest,
|
||||
"statusMessage": msg,
|
||||
})
|
||||
}
|
||||
|
||||
// nonEmpty 等价于 TS 中的 .filter(Boolean)
|
||||
func nonEmpty(parts []string) []string {
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// findURL 等价于 TS 中的 new Map(apiBaseUrls.map(...)).get(backendID)
|
||||
func findURL(list []apiInfo, id string) string {
|
||||
for _, api := range list {
|
||||
if api.ID == id {
|
||||
return api.URL
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func keysOf(m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// fetchURL 发起上游 GET 请求,返回上游原始状态码与响应 body。
|
||||
// 仅网络层错误(连接失败/超时/读失败)返回 error;上游任何 HTTP 状态码都原样透传,不包装成 500。
|
||||
func fetchURL(target string, headers map[string]string, query url.Values) (int, []byte, error) {
|
||||
if len(query) > 0 {
|
||||
target += "?" + query.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := HTTP_CLIENT.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return resp.StatusCode, body, nil
|
||||
}
|
||||
|
||||
// forwardUpstream 转发上游结果:网络层错误返回 502,上游任意状态码与 body 原样透传
|
||||
func forwardUpstream(c fiber.Ctx, apiBaseUrl string, target string, headers map[string]string, query url.Values) error {
|
||||
status, data, err := fetchURL(target, headers, query)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching from %s: %v", apiBaseUrl, err)
|
||||
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{
|
||||
"statusCode": fiber.StatusBadGateway,
|
||||
"statusMessage": "Backend unreachable",
|
||||
})
|
||||
}
|
||||
return c.Status(status).Type("json").Send(data)
|
||||
}
|
||||
|
||||
// lookupAPIKey 查找 backendID 对应的 token。
|
||||
// 优先级: setting.json apiKeys > 环境变量 APIKEYS (JSON 字符串)
|
||||
func lookupAPIKey(backendID string) string {
|
||||
var apiKeys map[string]string
|
||||
if len(API_KEYS) > 0 {
|
||||
apiKeys = API_KEYS
|
||||
} else if raw := os.Getenv("APIKEYS"); raw != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &apiKeys); err != nil {
|
||||
return ""
|
||||
}
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
|
||||
apiKey := apiKeys[backendID]
|
||||
|
||||
return apiKey
|
||||
}
|
||||
|
||||
// ==================== 业务接口 ====================
|
||||
|
||||
// middlewareHandler 对应前端 server/routes/middleware/[...slug].get.ts
|
||||
// 路径格式: /{prefix}/{backendID}/{apiType}/{raw...}
|
||||
func middlewareHandler(c fiber.Ctx) error {
|
||||
// fiber 默认不解码路径参数,而 h3 的 params 是解码后的,这里手动对齐
|
||||
slugString := c.Params("*")
|
||||
if decoded, err := url.PathUnescape(slugString); err == nil {
|
||||
slugString = decoded
|
||||
}
|
||||
if slugString == "" {
|
||||
return badRequest(c, "Missing slug parameter")
|
||||
}
|
||||
|
||||
// 将路径参数转为数组
|
||||
slug := strings.Split(slugString, "/")
|
||||
|
||||
// 验证 slug 数组的长度是否在允许范围内
|
||||
if len(slug) == 0 {
|
||||
return badRequest(c, "Invalid slug")
|
||||
}
|
||||
|
||||
// 如果分段超过4个,可能是 raw 部分包含协议(如 https://example.com),
|
||||
// 按 / 分割后会产生额外分段,需要重新拼回
|
||||
if len(slug) > 4 {
|
||||
backendID := slug[0]
|
||||
apiType := slug[1]
|
||||
protocol := slug[2]
|
||||
rest := strings.Join(nonEmpty(slug[3:]), "/")
|
||||
if protocol == "https:" || protocol == "http:" {
|
||||
slug = []string{backendID, apiType, protocol + "//" + rest}
|
||||
} else {
|
||||
return badRequest(c, "Invalid slug")
|
||||
}
|
||||
}
|
||||
|
||||
// 分割参数
|
||||
backendID := slug[0]
|
||||
apiType := slug[1]
|
||||
raw := strings.Join(slug[2:], "/")
|
||||
if backendID == "" || apiType == "" || raw == "" {
|
||||
return badRequest(c, "Missing parameters in slug")
|
||||
}
|
||||
|
||||
query := c.Queries()
|
||||
|
||||
// 从 API_KEYS 查找 token,未配置时回退到环境变量 APIKEYS (JSON 字符串)
|
||||
apiKey := lookupAPIKey(backendID)
|
||||
|
||||
// 构造转发给上游的请求头:所有接口统一透传客户端请求的 Origin,没有 Origin 头则不传
|
||||
authHeaders := map[string]string{}
|
||||
if origin := c.Get("Origin"); origin != "" {
|
||||
authHeaders["Origin"] = origin
|
||||
}
|
||||
if apiKey != "" {
|
||||
authHeaders["Authorization"] = "Bearer " + apiKey
|
||||
}
|
||||
|
||||
switch apiType {
|
||||
case "whois", "dns", "location", "ssl", "asn", "dnssec", "detail":
|
||||
var apiBaseUrls []apiInfo
|
||||
switch apiType {
|
||||
case "whois", "ssl", "detail":
|
||||
apiBaseUrls = API_BASE_URLS
|
||||
case "dns", "dnssec":
|
||||
apiBaseUrls = NS_LOOKUP
|
||||
case "location", "asn":
|
||||
apiBaseUrls = IP_LOCATION_APIS
|
||||
}
|
||||
|
||||
apiBaseUrl := findURL(apiBaseUrls, backendID)
|
||||
if apiBaseUrl == "" {
|
||||
return badRequest(c, "Invalid backend ID")
|
||||
}
|
||||
if !strings.HasSuffix(apiBaseUrl, "/") {
|
||||
apiBaseUrl += "/"
|
||||
}
|
||||
|
||||
// 上游错误原样透传(状态码 + body),网络错误返回 502
|
||||
return forwardUpstream(c, apiBaseUrl, apiBaseUrl+"v1/"+apiType+"/"+raw, authHeaders, nil)
|
||||
|
||||
case "tcping", "udping", "speed":
|
||||
var apiTypeConfig stackConfig
|
||||
switch apiType {
|
||||
case "tcping":
|
||||
apiTypeConfig = TCPING
|
||||
case "speed":
|
||||
apiTypeConfig = SPEED_TEST
|
||||
default:
|
||||
return badRequest(c, "Invalid API type")
|
||||
}
|
||||
|
||||
apiBaseUrls := make([]apiInfo, 0, len(apiTypeConfig.DualStack)+len(apiTypeConfig.IPv4)+len(apiTypeConfig.IPv6))
|
||||
apiBaseUrls = append(apiBaseUrls, apiTypeConfig.DualStack...)
|
||||
apiBaseUrls = append(apiBaseUrls, apiTypeConfig.IPv4...)
|
||||
apiBaseUrls = append(apiBaseUrls, apiTypeConfig.IPv6...)
|
||||
|
||||
apiBaseUrl := findURL(apiBaseUrls, backendID)
|
||||
if apiBaseUrl == "" {
|
||||
return badRequest(c, "Invalid backend ID")
|
||||
}
|
||||
if !strings.HasSuffix(apiBaseUrl, "/") {
|
||||
apiBaseUrl += "/"
|
||||
}
|
||||
|
||||
// 转发 query 参数(过滤空值,等价于 TS 中 URLSearchParams 过滤 undefined)
|
||||
queryString := url.Values{}
|
||||
for k, v := range query {
|
||||
if v != "" {
|
||||
queryString.Set(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// 上游错误原样透传(状态码 + body),网络错误返回 502
|
||||
return forwardUpstream(c, apiBaseUrl, apiBaseUrl+"v1/"+apiType+"/"+raw, authHeaders, queryString)
|
||||
|
||||
default:
|
||||
return badRequest(c, "Invalid API type")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== readConfig ====================
|
||||
|
||||
// readConfig 从 setting.json 读取配置并写入全局配置变量。
|
||||
// 查找顺序: 环境变量 SETTING_FILE > ./setting.json > ../setting.json
|
||||
func readConfig() error {
|
||||
path := os.Getenv("SETTING_FILE")
|
||||
if path == "" {
|
||||
for _, candidate := range []string{"setting.json", "../setting.json"} {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
path = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
return fmt.Errorf("setting.json not found (set SETTING_FILE to specify its path)")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var mw middlewareConfig
|
||||
if err := json.Unmarshal(data, &mw); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
|
||||
// 校验配置存在(防止在错误的文件上静默空跑)
|
||||
hasEndpoints := len(mw.APIBaseURLs) > 0 || len(mw.NSLookup) > 0 || len(mw.IPLocationAPIs) > 0 ||
|
||||
len(mw.TCPing.DualStack) > 0 || len(mw.TCPing.IPv4) > 0 || len(mw.TCPing.IPv6) > 0 ||
|
||||
len(mw.SpeedTest.DualStack) > 0 || len(mw.SpeedTest.IPv4) > 0 || len(mw.SpeedTest.IPv6) > 0
|
||||
if !hasEndpoints {
|
||||
return fmt.Errorf("invalid config in %s: missing endpoint lists (expected flat middleware config)", path)
|
||||
}
|
||||
|
||||
// 将配置内容写入全局变量
|
||||
PORT = string(mw.Port)
|
||||
HTTP_TIMEOUT = mw.HTTPTimeoutSeconds
|
||||
API_BASE_URLS = mw.APIBaseURLs
|
||||
IP_LOCATION_APIS = mw.IPLocationAPIs
|
||||
TCPING = mw.TCPing
|
||||
SPEED_TEST = mw.SpeedTest
|
||||
NS_LOOKUP = mw.NSLookup
|
||||
API_KEYS = mw.APIKeys
|
||||
CONFIG_SOURCE = path
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==================== main ====================
|
||||
|
||||
func main() {
|
||||
// -v / --version / version 参数打印版本信息(对齐根 main.go 的行为)
|
||||
for _, arg := range os.Args {
|
||||
if arg == "-v" || arg == "--version" || arg == "version" {
|
||||
fmt.Println("MIDDLEWARE-GO VERSION " + VERSION)
|
||||
fmt.Println("COMMIT " + COMMIT)
|
||||
fmt.Println("BUILD_TIME " + BUILD_TIME)
|
||||
return
|
||||
}
|
||||
}
|
||||
log.Printf("[middleware] version=%s commit=%s build_time=%s", VERSION, COMMIT, BUILD_TIME)
|
||||
|
||||
if err := readConfig(); err != nil {
|
||||
log.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
log.Printf("[middleware] config loaded from %s", CONFIG_SOURCE)
|
||||
|
||||
timeout := HTTP_TIMEOUT
|
||||
if timeout <= 0 {
|
||||
timeout = 30
|
||||
}
|
||||
HTTP_CLIENT.Timeout = time.Duration(timeout) * time.Second
|
||||
|
||||
app := fiber.New()
|
||||
|
||||
app.Get("/", func(c fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"status": "ok",
|
||||
})
|
||||
})
|
||||
|
||||
// 中间件路由,对应前端 server/routes/middleware/[...slug].get.ts
|
||||
// 同时兼容 /v1/... 与 /middleware/... 两种前缀
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{"GET", "POST", "HEAD", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
|
||||
MaxAge: 86400,
|
||||
}))
|
||||
app.Get("/v1/*", middlewareHandler)
|
||||
app.Get("/middleware/*", middlewareHandler)
|
||||
|
||||
// 监听地址,优先级: 环境变量 PORT > setting.json port > 默认 8080
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = PORT
|
||||
}
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
listenAddr := port
|
||||
if !strings.Contains(listenAddr, ":") {
|
||||
listenAddr = ":" + listenAddr
|
||||
}
|
||||
if err := app.Listen(listenAddr); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"port": "8091",
|
||||
"httpTimeoutSeconds": 30,
|
||||
"apiBaseUrls": [
|
||||
{
|
||||
"label": "中国 江苏 移动",
|
||||
"id": "cn-jiangsu",
|
||||
"url": "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
}
|
||||
],
|
||||
"IPLocationAPIs": [
|
||||
{
|
||||
"label": "中国 江苏 移动",
|
||||
"id": "cn-jiangsu",
|
||||
"url": "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
}
|
||||
],
|
||||
"TCPing": {
|
||||
"DualStack": [
|
||||
{
|
||||
"label": "中国 江苏 移动",
|
||||
"id": "cn-jiangsu",
|
||||
"url": "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
}
|
||||
],
|
||||
"IPv4": [
|
||||
],
|
||||
"IPv6": [
|
||||
]
|
||||
},
|
||||
"SpeedTest": {
|
||||
"DualStack": [
|
||||
{
|
||||
"label": "中国 江苏 移动",
|
||||
"id": "cn-jiangsu",
|
||||
"url": "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
}
|
||||
],
|
||||
"IPv4": [
|
||||
],
|
||||
"IPv6": [
|
||||
]
|
||||
},
|
||||
"NSLookup": [
|
||||
{
|
||||
"label": "中国 江苏 移动",
|
||||
"id": "cn-jiangsu",
|
||||
"url": "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
}
|
||||
],
|
||||
"apiKeys": {
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user