9 Commits

Author SHA1 Message Date
sakuradairong
169a60690d feat: add consistent top navigation bar to all pages
Some checks failed
Watch Mihomo Meta / watch (push) Has been cancelled
Sync Upstream Parser / sync (push) Has been cancelled
Build and Push Docker image / 🔧 Prepare Metadata (push) Has been cancelled
Build and Push Docker image / 🐳 Docker Build (${{ matrix.arch }}) (push) Has been cancelled
Build and Push Docker image / 🪟 Windows Build (amd64) (push) Has been cancelled
Build and Push Docker image / 🔗 Docker Manifest (push) Has been cancelled
Build and Push Docker image / 🚀 Release Artifacts (push) Has been cancelled
- Add topbar with brand icon, nav links (Version/Inspector/Dashboard),
  and language toggle to version page and inspect page
- Change both pages from centered-container layout to shell+topbar layout
- Update JavaScript lang toggle selector for consistency
- WebApp page: add Version/Inspect nav links and more quick-action buttons
2026-06-16 10:34:33 +08:00
sakuradairong
819c29b843 feat(webapp): add modern WebApp UI with tabs, presets and history
- Add tabbed interface (Basic/Advanced/History)
- Add quick action buttons for common formats
- Add preset system (Clash/Surge/QuanX/Sing-box/Node list)
- Add form persistence with localStorage
- Add conversion history with restore functionality
- Add URL format validation
- Add result statistics (size/lines/time)
- Add toast notifications
- Add light/dark theme support
- Add i18n (Chinese/English auto-detect)
- Add responsive mobile layout
- Update CMakeLists.txt and main.cpp routing
2026-06-15 15:33:41 +08:00
github-actions[bot]
f1f875a325 chore: sync dev to master
Some checks failed
Build and Push Docker image / 🔧 Prepare Metadata (push) Has been cancelled
Build and Push Docker image / 🐳 Docker Build (${{ matrix.arch }}) (push) Has been cancelled
Build and Push Docker image / 🪟 Windows Build (amd64) (push) Has been cancelled
Build and Push Docker image / 🔗 Docker Manifest (push) Has been cancelled
Build and Push Docker image / 🚀 Release Artifacts (push) Has been cancelled
2026-06-06 05:22:11 +00:00
github-actions[bot]
8175fa7fae chore: update auto-generated files and header libraries from build [skip ci] 2026-06-06 05:15:34 +00:00
Aethersailor
60bbadde5b fix(version): support lightweight backend probes 2026-06-06 13:11:48 +08:00
dependabot[bot]
a09aadb40f chore(deps): bump github.com/metacubex/mihomo from 1.19.25 to 1.19.26 in /bridge in the go-dependencies group (#73)
Bumps the go-dependencies group in /bridge with 1 update: [github.com/metacubex/mihomo](https://github.com/metacubex/mihomo).


Updates `github.com/metacubex/mihomo` from 1.19.25 to 1.19.26
- [Release notes](https://github.com/metacubex/mihomo/releases)
- [Commits](https://github.com/metacubex/mihomo/compare/v1.19.25...v1.19.26)

---
updated-dependencies:
- dependency-name: github.com/metacubex/mihomo
  dependency-version: 1.19.26
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-03 16:53:39 +00:00
Aethersailor
a178233898 docs(readme): refine dashboard overview 2026-06-02 10:05:34 +08:00
Aethersailor
f752e7fe47 docs(readme): polish folded usage sections 2026-06-02 10:00:01 +08:00
Aethersailor
52afa7d3e6 docs(readme): add diagnostics and dashboard docs 2026-06-02 09:53:08 +08:00
14 changed files with 2348 additions and 142 deletions

View File

@@ -76,6 +76,7 @@ ADD_EXECUTABLE(${BUILD_TARGET_NAME}
src/handler/dashboard_page.cpp
src/handler/inspect_page.cpp
src/handler/interfaces.cpp
src/handler/webapp_page.cpp
src/handler/multithread.cpp
src/handler/statistics.cpp
src/handler/upload.cpp

141
README.md
View File

@@ -192,13 +192,29 @@ https://github.com/<owner>/<repo>/raw/<ref>/<path>
https://github.com/<owner>/<repo>/blob/<ref>/<path>
```
#### 4. 兼容性保证 🤝
#### 4. 请求诊断台 🔎
内置 `explain=true` 诊断模式和 `/inspect` 网页诊断台,方便在不改变实际转换逻辑的前提下排查请求:
* ✅ 展示请求参数是否被识别、是否生效、是否被项目安全逻辑覆盖
* ✅ 汇总外部配置、规则集、自定义组、Provider、输出大小等关键状态
* ✅ 对订阅来源等敏感信息只展示预览、长度或短哈希,便于排障时降低泄露风险
#### 5. 运行仪表盘 📊
启用统计后,`/dashboard` 可展示服务运行期转换统计,适合公开服务部署者观察后端使用情况:
* ✅ 展示本次启动、历史总计、最近 24 小时和滚动时间窗口统计、访问者地理位置分布
* ✅ 按请求数和规则转换数展示国家 / 地区分布与排行
* ✅ 支持统计数据持久化和可选 Basic Auth 验证,便于公网部署时限制访问
#### 6. 兼容性保证 🤝
***无缝切换**:兼容常见传统 subconverter API 接口,客户端侧几乎无需学习成本即可迁移
***模板兼容**:继续沿用传统外部模板,由后端内置逻辑确保 `proxy-provider` 模式在分流规则中正确生成
***自动跟进**:编译时自动遍历 [Mihomo 内核源码仓库](https://github.com/MetaCubeX/mihomo/meta),提取最新解析模块、协议格式与可覆写参数
#### 5. 新手友好 👶
#### 7. 新手友好 👶
* ✅ 使用 **[Custom_OpenClash_Rules](https://github.com/Aethersailor/Custom_OpenClash_Rules)** 远程配置模板,替代默认内置模板与自定义代理组功能
* ✅ 锁定 API 模式,强制关闭相关接口,降低新手误配置带来的安全风险
@@ -532,6 +548,9 @@ logread -e subconverter
> [!IMPORTANT]
> 默认输出为**最简配置**,不包含 DNS 参数,请在各 Clash 客户端中启用 DNS 覆写功能,或在生成的配置文件中自行补全 DNS 配置。
<details open>
<summary><strong>快速调用与常用参数</strong></summary>
### 常用参数一览
| 参数 | 说明 | 示例 |
@@ -554,6 +573,11 @@ https://api.asailor.org/sub?target=clash&url=https%3A%2F%2Fexample.com%2Fsub&con
https://api.asailor.org/sub?target=clash&url=provider%3AHK%2Chttps%3A%2F%2Fexample.com%2Fsub&include=%E9%A6%99%E6%B8%AF&emoji=true
```
</details>
<details>
<summary><strong>诊断与排障</strong></summary>
### `explain=true` 诊断模式
`/sub` 请求中追加 `explain=true` 后,后端会按同一组参数执行转换流程,但返回 JSON 诊断报告,而不是返回 Clash/Surge/QuanX 配置文件。
@@ -566,10 +590,11 @@ https://api.asailor.org/sub?target=clash&url=https%3A%2F%2Fexample.com%2Fsub&exp
这个模式适合排查“参数是否生效”“是否进入 `proxy-provider` 模式”“外部配置是否加载成功”“规则集和节点数量是否符合预期”等问题。报告会包含目标格式、模式开关、输入数量、外部配置状态、规则集统计、provider 数量和输出大小等信息。
> [!NOTE]
> * `explain=true` 只改变响应内容,不改变实际转换逻辑。
> * 如果同一请求里包含上传参数,诊断模式会抑制上传,避免排障时产生托管配置写入
> * 诊断报告不会直接回显原始订阅地址provider 来源会以短哈希形式显示,便于区分来源又避免泄露完整链接
**说明:**
* `explain=true` 只改变响应内容,不改变实际转换逻辑
* 如果同一请求里包含上传参数,诊断模式会抑制上传,避免排障时产生托管配置写入
* 诊断报告不会直接回显原始订阅地址provider 来源会以短哈希形式显示,便于区分来源又避免泄露完整链接。
### `/inspect` 请求诊断台
@@ -594,10 +619,101 @@ http://localhost:25500/inspect
* `include` / `exclude``emoji``new_name``config` 等外部参数最终是否参与转换
* 外部配置、规则集、自定义组、Provider 是否按预期加载或生成
> [!NOTE]
> * `/inspect` 只是 `explain=true` 诊断报告的可视化界面,不会改变实际转换逻辑。
> * 页面会隐藏敏感输入的明文,仅展示预览、长度和短哈希等排障信息
> * 请求诊断台会保留原始 JSON 区域,方便复制给维护者进一步分析
**说明:**
* `/inspect` 只是 `explain=true` 诊断报告的可视化界面,不会改变实际转换逻辑
* 页面会隐藏敏感输入的明文,仅展示预览、长度和短哈希等排障信息
* 请求诊断台会保留原始 JSON 区域,方便复制给维护者进一步分析。
</details>
<details>
<summary><strong>/dashboard 运行仪表盘</strong></summary>
### `/dashboard` 使用方法
`/dashboard` 用于查看运行期转换统计。该功能默认关闭;只有在配置文件中启用 `statistics.enabled` 后,服务才会注册 `/dashboard``/dashboard/data` 路由。
启用后可访问:
```text
http://localhost:25500/dashboard
```
公网或反代部署时,请替换为实际域名:
```text
https://sub.example.com/dashboard
```
`/dashboard/data` 会返回仪表盘使用的 JSON 数据,适合接入外部监控或自行排查:
```text
http://localhost:25500/dashboard/data
```
仪表盘主要展示:
* 服务启动时间、本次运行时长、累计运行时长和启动次数
* 成功 `/sub` 转换请求数与规则转换数
* 最近 24 小时请求 / 规则转换柱状图
* 按 1 小时、1 天、7 天、30 天、半年、1 年和历史总计统计的国家 / 地区分布与排行
* 当可信边缘网关提供地区请求头时,展示中国地区请求 / 规则转换地图和排行
**说明:**
* 统计只在 `statistics.enabled=true` 后开始写入,启用前的历史请求不会回补。
* 统计模块只记录成功的 `GET /sub` 转换请求和规则转换计数,不存储订阅链接、节点内容或访问者 IP。
* 国家 / 地区来源于配置的国家码请求头;中国地区来源于配置的地区请求头;无法识别时会归为未知。
* Docker 部署如需跨重启保留统计数据,请将 `data_dir` 对应目录挂载为卷,例如 `./stats:/base/stats`
### 启用示例TOML
修改 `base/pref.toml` 后重启服务:
```toml
[statistics]
enabled = true
data_dir = "stats"
flush_interval = 5
[statistics.geo]
provider = "header"
country_headers = ["CF-IPCountry", "X-Geo-Country", "X-Vercel-IP-Country", "CloudFront-Viewer-Country"]
china_region_headers = ["CF-Region-Code", "cf-region-code", "X-Geo-Subdivision"]
[statistics.dashboard_auth]
enabled = true
username = "admin"
password = "change-this-password"
max_failures = 5
window_seconds = 300
lock_seconds = 900
```
### 新增配置项说明
| TOML / YAML 配置项 | INI 配置项 | 默认值 | 说明 |
| :--- | :--- | :--- | :--- |
| `statistics.enabled` | `enabled` | `false` | 是否启用运行期统计和 `/dashboard`。关闭时不会注册 `/dashboard``/dashboard/data`。 |
| `statistics.data_dir` | `data_dir` | `stats` | 统计数据目录按程序工作目录解析Docker 中可挂载 `/base/stats` 持久化。 |
| `statistics.flush_interval` | `flush_interval` | `5` | 统计数据最小写盘间隔,单位为秒。 |
| `statistics.geo.provider` | `geo_provider` | `header` | 国家 / 地区识别方式。`header` 表示读取国家码请求头,`none` 表示全部记为未知。 |
| `statistics.geo.country_headers` | `country_headers` | `CF-IPCountry`, `X-Geo-Country`, `X-Vercel-IP-Country`, `CloudFront-Viewer-Country` | `provider=header` 时依次尝试读取的国家码请求头。 |
| `statistics.geo.china_region_headers` | `china_region_headers` | `CF-Region-Code`, `cf-region-code`, `X-Geo-Subdivision` | 可信边缘网关注入中国地区码时依次尝试读取的请求头,用于中国地区地图和排行。 |
| `statistics.dashboard_auth.enabled` | `dashboard_auth_enabled` | `false` | 是否为 `/dashboard``/dashboard/data` 启用 Basic Auth。 |
| `statistics.dashboard_auth.username` | `dashboard_auth_username` | 空 | Basic Auth 用户名。启用认证后不能为空。 |
| `statistics.dashboard_auth.password` | `dashboard_auth_password` | 空 | Basic Auth 密码。启用认证后不能为空;公网部署建议配合 HTTPS。 |
| `statistics.dashboard_auth.max_failures` | `dashboard_auth_max_failures` | `5` | 在统计窗口内允许的失败登录次数。 |
| `statistics.dashboard_auth.window_seconds` | `dashboard_auth_window_seconds` | `300` | 失败登录统计窗口,单位为秒。 |
| `statistics.dashboard_auth.lock_seconds` | `dashboard_auth_lock_seconds` | `900` | 超过失败次数后的锁定时长,单位为秒。 |
**提示:** `pref.yml` 使用同名嵌套字段;`pref.ini` 的上述 INI 配置项均写在 `[statistics]` 段内。
</details>
<details>
<summary><strong>Proxy-Provider 自定义名称</strong></summary>
### `provider` 前缀(仅适用于 Clash/ClashR 订阅链接)
@@ -611,14 +727,15 @@ url=provider:HK,https://a|provider:HK,https://b
url=provider%3AHK%2Chttps%3A%2F%2Fexample.com%2Fsub
```
> [!NOTE]
> 在 OpenClash 这类预置“订阅地址”输入框的软件中,无需填写开头的 `url=`,直接填入等号后的内容即可。
**说明:** 在 OpenClash 这类预置“订阅地址”输入框的软件中,无需填写开头的 `url=`,直接填入等号后的内容即可。
补充说明:
* 支持中文名称;非法字符或空值会回退为默认 `Provider_<MD5>`
* 重名时会自动追加 `_1``_2` 等后缀
</details>
---
## 🛠️ 配置说明

394
base/pref.toml Normal file
View File

@@ -0,0 +1,394 @@
version = 1
[common]
# API mode is hardcoded to true for security - cannot be configured
# Token authentication is disabled - users must provide url parameter
# Default URLs, used when no URL is provided in request, use "|" to separate multiple subscription links, supports local files/URL
default_url = []
# Insert subscription links to requests. Can be used to add node(s) to all exported subscriptions.
enable_insert = true
# URLs to insert before subscription links, can be used to add node(s) to all exported subscriptions, supports local files/URL
insert_url = [""]
# Prepend inserted URLs to subscription links. Nodes in insert_url will be added to groups first with non-group-specific match pattern.
prepend_insert_url = true
# Exclude nodes which remarks match the following patterns. Supports regular expression.
exclude_remarks = ["(到期|剩余流量|时间|官网|产品)"]
# Only include nodes which remarks match the following patterns. Supports regular expression.
#include_remarks = ["V3.*港"]
# Enable script support for filtering nodes
enable_filter = false
# Script used for filtering nodes. Supports inline script and script path. A "filter" function with 1 argument which is a node should be defined in the script.
# Example: Inline script: set value to content of script.
# Script path: set value to "path:/path/to/script.js".
#filter_script = '''
#function filter(node) {
# const info = JSON.parse(node.ProxyInfo);
# if(info.EncryptMethod.includes('chacha20'))
# return true;
# return false;
#}
#'''
default_external_config = "https://testingcf.jsdelivr.net/gh/Aethersailor/Custom_OpenClash_Rules@refs/heads/main/cfg/Custom_Clash.ini"
# The file scope limit of the 'rule_base' options in external configs.
base_path = "base"
# Clash config base used by the generator, supports local files/URL
clash_rule_base = "base/all_base.tpl"
# Surge config base used by the generator, supports local files/URL
surge_rule_base = "base/all_base.tpl"
# Surfboard config base used by the generator, supports local files/URL
surfboard_rule_base = "base/all_base.tpl"
# Mellow config base used by the generator, supports local files/URL
mellow_rule_base = "base/all_base.tpl"
# Quantumult config base used by the generator, supports local files/URL
quan_rule_base = "base/all_base.tpl"
# Quantumult X config base used by the generator, supports local files/URL
quanx_rule_base = "base/all_base.tpl"
# Loon config base used by the generator, supports local files/URL
loon_rule_base = "base/all_base.tpl"
# Shadowsocks Android config base used by the generator, supports local files/URL
sssub_rule_base = "base/all_base.tpl"
# sing-box config base used by the generator, supports local files/URL
singbox_rule_base = "base/all_base.tpl"
# Proxy used to download rulesets or subscriptions, set to NONE or empty to disable it, set to SYSTEM to use system proxy.
# Accept cURL-supported proxies (http:// https:// socks4a:// socks5://)
proxy_config = "SYSTEM"
proxy_ruleset = "SYSTEM"
proxy_subscription = "NONE"
# Append a proxy type string ([SS] [SSR] [VMess]) to node remark.
append_proxy_type = false
# When requesting /sub, reload this config file first.
reload_conf_on_request = false
[[userinfo.stream_rule]]
# Rules to extract stream data from node
# Format: full_match_regex|new_format_regex
# where new_format_regex should be like "total=$1&left=$2&used=$3"
match = '^剩余流量:(.*?)\|总流量:(.*)$'
replace = 'total=$2&left=$1'
[[userinfo.stream_rule]]
match = '^剩余流量:(.*?) (.*)$'
replace = 'total=$1&left=$2'
[[userinfo.stream_rule]]
match = '^Bandwidth: (.*?)/(.*)$'
replace = 'used=$1&total=$2'
[[userinfo.stream_rule]]
match = '^.*剩余(.*?)(?:\s*?)@(?:.*)$'
replace = 'total=$1'
[[userinfo.time_rule]]
# Rules to extract expire time data from node
# Format: full_match_regex|new_format_regex
# where new_format_regex should follow this example: yyyy:mm:dd:hh:mm:ss
match = '^过期时间:(\d+)-(\d+)-(\d+) (\d+):(\d+):(\d+)$'
replace = '$1:$2:$3:$4:$5:$6'
[[userinfo.time_rule]]
match = '^到期时间:(\d+)-(\d+)-(\d+)$'
replace = '$1:$2:$3:0:0:0'
[[userinfo.time_rule]]
match = '^Smart Access expire: (\d+)/(\d+)/(\d+)$'
replace = '$1:$2:$3:0:0:0'
[node_pref]
#udp_flag = false
#tcp_fast_open_flag = false
#skip_cert_verify_flag = false
#tls13_flag = false
sort_flag = false
# Script used for sorting nodes. A "compare" function with 2 arguments which are the 2 nodes to be compared should be defined in the script. Supports inline script and script path.
# Examples can be seen at the filter_script option in [common] section.
#sort_script = '''
#function compare(node_a, node_b) {
# return info_a.Remark > info_b.Remark;
#}
#'''
filter_deprecated_nodes = false
append_sub_userinfo = true
clash_use_new_field_name = true
# Generate style of the proxies and proxy groups section of Clash subscriptions.
# Supported styles: block, flow, compact
# Block: - name: name1 Flow: - {name: name1, key: value} Compact: [{name: name1, key: value},{name: name2, key: value}]
# key: value - {name: name2, key: value}
# - name: name2
# key: value
clash_proxies_style = "flow"
clash_proxy_groups_style = "flow"
# add Clash mode to sing-box rules, and add a GLOBAL group to end of outbounds
singbox_add_clash_modes = true
[[node_pref.rename_node]]
match = '\(?((x|X)?(\d+)(\.?\d+)?)((\s?倍率?)|(x|X))\)?'
replace = "$1x"
[managed_config]
# Append a '#!MANAGED-CONFIG' info to Surge configurations
write_managed_config = true
# Address prefix for MANAGED-CONFIG info, without the trailing "/".
managed_config_prefix = "http://127.0.0.1:25500"
# Managed config update interval in seconds, determine how long the config will be updated.
config_update_interval = 86400
# If config_update_strict is set to true, Surge will require a force update after the interval.
config_update_strict = false
# Device ID to be written to rewrite scripts for some version of Quantumult X
quanx_device_id = ""
[surge_external_proxy]
#surge_ssr_path = "/usr/bin/ssr-local"
resolve_hostname = true
[security]
# Security profile:
# lan - default, legacy behavior for private/LAN deployments. Local, private
# and fake-ip resources are allowed.
# public - for Internet-facing deployments. Only untrusted request-controlled
# fetches are restricted; built-in local templates and trusted config
# files continue to work.
# strict - same public fetch restrictions, and public upload cannot be enabled.
# Environment override: SUBCONVERTER_SECURITY_PROFILE=lan|public|strict
profile = "lan"
# Only used by public profile. lan keeps legacy upload behavior; strict always
# disables public upload.
# Environment override: SUBCONVERTER_ALLOW_PUBLIC_UPLOAD=true|false
allow_public_upload = false
[statistics]
# Opt-in runtime statistics and /dashboard. Missing or false keeps it disabled
# and avoids registering the dashboard or statistics-enabled request handler.
enabled = false
# Put this directory on a Docker volume if statistics should survive restarts.
data_dir = "stats"
# Minimum seconds between persistence writes.
flush_interval = 5
[statistics.geo]
# header uses country-only headers such as CF-IPCountry and never stores IPs.
# none records all countries as unknown.
provider = "header"
country_headers = ["CF-IPCountry", "X-Geo-Country", "X-Vercel-IP-Country", "CloudFront-Viewer-Country"]
[statistics.dashboard_auth]
# Optional Basic authentication for /dashboard and /dashboard/data.
# Only applies when statistics.enabled is true.
# Missing or false keeps the dashboard password disabled.
enabled = false
username = ""
password = ""
# Failed login attempts allowed within window_seconds before lock_seconds applies.
max_failures = 5
window_seconds = 300
lock_seconds = 900
[emojis]
add_emoji = false
remove_old_emoji = true
[[emojis.emoji]]
#match = '(流量|时间|应急)'
#emoji = '🏳️‍🌈'
import = "snippets/emoji.toml"
# [[custom_groups]]
# name = "Auto"
# type = "url-test"
# rule = [".*"]
# url = "http://www.gstatic.com/generate_204"
# interval = 300
# tolerance = 150
# lazy = true
# [[custom_groups]]
# name = "Proxy"
# type = "select"
# rule = [".*", "[]DIRECT"]
# disable_udp = false
# [[custom_groups]]
# name = "LoadBalance"
# type = "load-balance"
# rule = [".*", "[]Proxy", "[]DIRECT"]
# interval = 100
# strategy = "consistent-hashing"
# url = "http://www.gstatic.com/generate_204"
[[custom_groups]]
import = "snippets/groups.toml"
[ruleset]
# Enable generating rules with rulesets
enabled = true
# Overwrite the existing rules in rule_base
overwrite_original_rules = false
# Perform a ruleset update on request
update_ruleset_on_request = false
# [[rulesets]]
# group = "Proxy"
# ruleset = "https://raw.githubusercontent.com/DivineEngine/Profiles/master/Surge/Ruleset/Unbreak.list"
# type = "surge-ruleset"
# interval = 86400
[[rulesets]]
import = "snippets/rulesets.toml"
[template]
template_path = ""
[[template.globals]]
key = "clash.http_port"
value = "7890"
[[template.globals]]
key = "clash.socks_port"
value = "7891"
[[template.globals]]
key = "clash.allow_lan"
value = "true"
[[template.globals]]
key = "clash.log_level"
value = "info"
[[template.globals]]
key = "clash.external_controller"
value = "127.0.0.1:9090"
[[template.globals]]
key = "singbox.allow_lan"
value = "true"
[[template.globals]]
key = "singbox.mixed_port"
value = "2080"
[[aliases]]
uri = "/clash"
target = "/sub?target=clash"
[[aliases]]
uri = "/clashr"
target = "/sub?target=clashr"
[[aliases]]
uri = "/surge"
target = "/sub?target=surge"
[[aliases]]
uri = "/quan"
target = "/sub?target=quan"
[[aliases]]
uri = "/quanx"
target = "/sub?target=quanx"
[[aliases]]
uri = "/mellow"
target = "/sub?target=mellow"
[[aliases]]
uri = "/surfboard"
target = "/sub?target=surfboard"
[[aliases]]
uri = "/loon"
target = "/sub?target=loon"
[[aliases]]
uri = "/singbox"
target = "/sub?target=singbox"
[[aliases]]
uri = "/ss"
target = "/sub?target=ss"
[[aliases]]
uri = "/ssd"
target = "/sub?target=ssd"
[[aliases]]
uri = "/sssub"
target = "/sub?target=sssub"
[[aliases]]
uri = "/ssr"
target = "/sub?target=ssr"
[[aliases]]
uri = "/v2ray"
target = "/sub?target=v2ray"
[[aliases]]
uri = "/trojan"
target = "/sub?target=trojan"
[[aliases]]
uri = "/test"
target = "/render?path=templates/test.tpl"
#[[tasks]]
#name = "tick"
#cronexp = "0/10 * * * * ?"
#path = "tick.js"
#timeout = 3
[server]
listen = "0.0.0.0"
port = 25500
serve_file_root = "web"
[advanced]
log_level = "info"
print_debug_info = false
max_pending_connections = 10240
max_concurrent_threads = 16
# Maximum HTTP worker threads during bursts. Requests above this limit remain
# queued instead of being rejected.
max_server_threads = 128
max_allowed_rulesets = 64
max_allowed_rules = 0
max_allowed_download_size = 0
enable_cache = true
cache_subscription = 60
cache_config = 300
cache_ruleset = 21600
script_clean_context = true
async_fetch_ruleset = true
skip_failed_links = true
enable_request_coalescing = true
coalesce_retry_on_5xx = true
# 0 disables completed response caching. If enabled, values above 5 seconds are clamped to 5.
response_cache_ttl = 0

View File

@@ -2,7 +2,7 @@ module github.com/aethersailor/subconverter-extended/bridge
go 1.25.5
require github.com/metacubex/mihomo v1.19.25
require github.com/metacubex/mihomo v1.19.26
require (
github.com/RyuaNerin/go-krypto v1.3.0 // indirect
@@ -24,7 +24,7 @@ require (
github.com/metacubex/randv2 v0.2.0 // indirect
github.com/metacubex/sing v0.5.7 // indirect
github.com/metacubex/sing-shadowsocks v0.2.12 // indirect
github.com/metacubex/tls v0.1.5 // indirect
github.com/metacubex/tls v0.1.6 // indirect
github.com/oasisprotocol/deoxysii v0.0.0-20220228165953-2091330c22b7 // indirect
github.com/samber/lo v1.53.0 // indirect
github.com/sina-ghaderi/poly1305 v0.0.0-20220724002748-c5926b03988b // indirect

View File

@@ -21,8 +21,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0=
github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
github.com/enfein/mieru/v3 v3.31.0 h1:Fl2ocRCRXJzMygzdRjBHgqI996ZuIDHUmyQyovSf9sA=
github.com/enfein/mieru/v3 v3.31.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
github.com/enfein/mieru/v3 v3.33.0 h1:hv2jK8nqYHwpSG86U2rpZR2I8Aff1/J3ifRmd9NBbFc=
github.com/enfein/mieru/v3 v3.33.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 h1:kXYqH/sL8dS/FdoFjr12ePjnLPorPo2FsnrHNuXSDyo=
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358/go.mod h1:hkIFzoiIPZYxdFOOLyDho59b7SrDfo+w3h+yWdlg45I=
github.com/ericlagergren/polyval v0.0.0-20230805202542-18692a1b76f9 h1:NUmyvuwVoDsIFzOGFKW4zpCtQTbX2T4JpSn1jal64gM=
@@ -109,18 +109,18 @@ github.com/metacubex/hpke v0.1.0 h1:gu2jUNhraehWi0P/z5HX2md3d7L1FhPQE6/Q0E9r9xQ=
github.com/metacubex/hpke v0.1.0/go.mod h1:vfDm6gfgrwlXUxKDkWbcE44hXtmc1uxLDm2BcR11b3U=
github.com/metacubex/http v0.1.6 h1:xvXuvXMCMxCWMF5nEJF4yiKvXL+p2atWMzs37e80m1I=
github.com/metacubex/http v0.1.6/go.mod h1:Nxx0zZAo2AhRfanyL+fmmK6ACMtVsfpwIl1aFAik2Eg=
github.com/metacubex/jsonv2 v0.0.0-20260513175203-1c6abea7534c h1:KGhBHDe6FveU0ury+9RyX329nclM1CHODa0Fi+uOAYM=
github.com/metacubex/jsonv2 v0.0.0-20260513175203-1c6abea7534c/go.mod h1:F4sVXat6QjPXkNsKRDyyG3BhSkxPFFnRPEIwmmyCgbg=
github.com/metacubex/jsonv2 v0.0.0-20260518173308-f4597c22f1df h1:S0vBzqjXok24VopstOgPd1JdgglW9tXehrqvwpQWbQ8=
github.com/metacubex/jsonv2 v0.0.0-20260518173308-f4597c22f1df/go.mod h1:F4sVXat6QjPXkNsKRDyyG3BhSkxPFFnRPEIwmmyCgbg=
github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604 h1:hJwCVlE3ojViC35MGHB+FBr8TuIf3BUFn2EQ1VIamsI=
github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604/go.mod h1:lpmN3m269b3V5jFCWtffqBLS4U3QQoIid9ugtO+OhVc=
github.com/metacubex/mihomo v1.19.25 h1:gVoiMx4jljDeGzfxXj3XlyY6clTk7npY+SGfZg8bBYQ=
github.com/metacubex/mihomo v1.19.25/go.mod h1:2wPHhdSY53InPrws2ZyzT1rPZgfy9eiag1aNsId1sZE=
github.com/metacubex/mihomo v1.19.26 h1:zTOrwEzgji2N6jFZwe6411hCbKmV1VGxVYZFKriX6uw=
github.com/metacubex/mihomo v1.19.26/go.mod h1:+L7tiesjMrF9I8lzTRsAFmHM9yh9RYdMEQP2tYnJqa8=
github.com/metacubex/mlkem v0.1.0 h1:wFClitonSFcmipzzQvax75beLQU+D7JuC+VK1RzSL8I=
github.com/metacubex/mlkem v0.1.0/go.mod h1:amhaXZVeYNShuy9BILcR7P0gbeo/QLZsnqCdL8U2PDQ=
github.com/metacubex/qpack v0.6.0 h1:YqClGIMOpiRYLjV1qOs483Od08MdPgRnHjt90FuaAKw=
github.com/metacubex/qpack v0.6.0/go.mod h1:lKGSi7Xk94IMvHGOmxS9eIei3bvIqpOAImEBsaOwTkA=
github.com/metacubex/quic-go v0.59.1-0.20260413153657-53bb22f2c306 h1:HlGLmLsWJMLSu0CMI9z/BmEnithB4oXM5Rom6/0Qxtg=
github.com/metacubex/quic-go v0.59.1-0.20260413153657-53bb22f2c306/go.mod h1:oNzMrmylS897M3zSMuapIdwSwfq6F2qW01Z3NhVRJhk=
github.com/metacubex/quic-go v0.59.1-0.20260520020949-fcd18c7b6ace h1:KXacx7dp1GYVMgxezwXRt5BMsEbvAYuA6rPFUmdAvcQ=
github.com/metacubex/quic-go v0.59.1-0.20260520020949-fcd18c7b6ace/go.mod h1:2YEQEvFrZ5V76oynMBDTlN+4fdnSHCa2uNJxv3cm1HU=
github.com/metacubex/randv2 v0.2.0 h1:uP38uBvV2SxYfLj53kuvAjbND4RUDfFJjwr4UigMiLs=
github.com/metacubex/randv2 v0.2.0/go.mod h1:kFi2SzrQ5WuneuoLLCMkABtiBu6VRrMrWFqSPyj2cxY=
github.com/metacubex/restls-client-go v0.1.7 h1:eCwiXCTQb5WJu9IlgYvDBA1OgrINv58dEe7hcN5H15k=
@@ -129,30 +129,30 @@ github.com/metacubex/sing v0.5.7 h1:8OC+fhKFSv/l9ehEhJRaZZAOuthfZo68SteBVLe8QqM=
github.com/metacubex/sing v0.5.7/go.mod h1:ypf0mjwlZm0sKdQSY+yQvmsbWa0hNPtkeqyRMGgoN+w=
github.com/metacubex/sing-mux v0.3.9 h1:/aoBD2+sK2qsXDlNDe3hkR0GZuFDtwIZhOeGUx9W0Yk=
github.com/metacubex/sing-mux v0.3.9/go.mod h1:8bT7ZKT3clRrJjYc/x5CRYibC1TX/bK73a3r3+2E+Fc=
github.com/metacubex/sing-quic v0.0.0-20260512151354-8475655be853 h1:nZ5WNU6kjj6kBu4+2eMySFkUVGCop64rZnLMm+HPh8w=
github.com/metacubex/sing-quic v0.0.0-20260512151354-8475655be853/go.mod h1:6ayFGfzzBE85csgQkM3gf4neFq6s0losHlPRSxY+nuk=
github.com/metacubex/sing-quic v0.0.0-20260527143057-68e10a6afdc3 h1:PnMby5+kZXTl/CFDHfxMbMTaSRD+uMKMsrDYVQyAmX8=
github.com/metacubex/sing-quic v0.0.0-20260527143057-68e10a6afdc3/go.mod h1:6ayFGfzzBE85csgQkM3gf4neFq6s0losHlPRSxY+nuk=
github.com/metacubex/sing-shadowsocks v0.2.12 h1:Wqzo8bYXrK5aWqxu/TjlTnYZzAKtKsaFQBdr6IHFaBE=
github.com/metacubex/sing-shadowsocks v0.2.12/go.mod h1:2e5EIaw0rxKrm1YTRmiMnDulwbGxH9hAFlrwQLQMQkU=
github.com/metacubex/sing-shadowsocks2 v0.2.7 h1:hSuuc0YpsfiqYqt1o+fP4m34BQz4e6wVj3PPBVhor3A=
github.com/metacubex/sing-shadowsocks2 v0.2.7/go.mod h1:vOEbfKC60txi0ca+yUlqEwOGc3Obl6cnSgx9Gf45KjE=
github.com/metacubex/sing-shadowtls v0.0.0-20250503063515-5d9f966d17a2 h1:gXU+MYPm7Wme3/OAY2FFzVq9d9GxPHOqu5AQfg/ddhI=
github.com/metacubex/sing-shadowtls v0.0.0-20250503063515-5d9f966d17a2/go.mod h1:mbfboaXauKJNIHJYxQRa+NJs4JU9NZfkA+I33dS2+9E=
github.com/metacubex/sing-shadowtls v0.0.0-20260517015314-c11c36474edc h1:8wLoFfYQ88iGPL+krQ5tJsI8IAmkFjKpQL2q+y3pvss=
github.com/metacubex/sing-shadowtls v0.0.0-20260517015314-c11c36474edc/go.mod h1:mbfboaXauKJNIHJYxQRa+NJs4JU9NZfkA+I33dS2+9E=
github.com/metacubex/sing-vmess v0.2.5 h1:m9Zt5I27lB9fmLMZfism9sH2LcnAfShZfwSkf6/KJoE=
github.com/metacubex/sing-vmess v0.2.5/go.mod h1:AwtlzUgf8COe9tRYAKqWZ+leDH7p5U98a0ZUpYehl8Q=
github.com/metacubex/sing-wireguard v0.0.0-20260507084707-690d479ec947 h1:IB03BvRQtvjWScyOK5jSQVJYY8osmZXHL+4VCEFMWcM=
github.com/metacubex/sing-wireguard v0.0.0-20260507084707-690d479ec947/go.mod h1:jpAkVLPnCpGSfNyVmj6Cq4YbuZsFepm/Dc+9BAOcR80=
github.com/metacubex/sing-wireguard v0.0.0-20260520151737-7e7c7c1b854c h1:tH9FuQW357zp2xAGzkoZTGpNGMVmEFZov0iV5M2S5ew=
github.com/metacubex/sing-wireguard v0.0.0-20260520151737-7e7c7c1b854c/go.mod h1:eQZDJTx+IH3k4mXqaOJ3VJ9h9ZqOl60F7TLi5wAU51Q=
github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 h1:DK2l6m2Fc85H2BhiAPgbJygiWhesPlfGmF+9Vw6ARdk=
github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141/go.mod h1:/yI4OiGOSn0SURhZdJF3CbtPg3nwK700bG8TZLMBvAg=
github.com/metacubex/ssh v0.1.0 h1:iGfr99qk/eMHzUnQ/0bTxXT8+8SWqLSHBWDHoAhngzw=
github.com/metacubex/ssh v0.1.0/go.mod h1:NUtl0d+/f2cG9ECEpMM8iCVOpmggQlC13oLeDUONDlU=
github.com/metacubex/tailscale v0.0.0-20260516120020-a21c2c99dcbe h1:ZdAKshacNruZGuKTE8WMTuxyGgpv/LySLoE/EEmgF9c=
github.com/metacubex/tailscale v0.0.0-20260516120020-a21c2c99dcbe/go.mod h1:2G1V82OGXgxT7m7046GA80I9SlcvczljCK0C7NQ3c10=
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260513233728-8bc7ee255d04 h1:zk+mDDSBl5lv80WWtaFUbpj8XLb7AhjCUbn2pB37N0U=
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260513233728-8bc7ee255d04/go.mod h1:pKUKBy7IcQ5r0i66gWENHgxKvBn8tlgAGx0DZMq8h5M=
github.com/metacubex/tailscale v0.0.0-20260520011538-f23132fac4b7 h1:LoJR4NMyNKHeEJoeGDtcsao7sV0NRkzMeV5H/0J0MIE=
github.com/metacubex/tailscale v0.0.0-20260520011538-f23132fac4b7/go.mod h1:MAo3HhE7968rIwmDvYTYE8xCsV4x+hLnkChdXeP3X4c=
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260521124654-e1bf77ef79af h1:c60IbBMUq2h1M2m7+grMJJmBmrObxL8SwvNtm6Ozbwk=
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260521124654-e1bf77ef79af/go.mod h1:i3zLKytWkOnyT1i9OmiLevWvrN5J5HE1+yjE7UYNfcQ=
github.com/metacubex/tfo-go v0.0.0-20251130171125-413e892ac443 h1:H6TnfM12tOoTizYE/qBHH3nEuibIelmHI+BVSxVJr8o=
github.com/metacubex/tfo-go v0.0.0-20251130171125-413e892ac443/go.mod h1:l9oLnLoEXyGZ5RVLsh7QCC5XsouTUyKk4F2nLm2DHLw=
github.com/metacubex/tls v0.1.5 h1:ECcB83dj+zadnhlKcLnUUf1Sq6+vU0f/zoyU0+9oPTc=
github.com/metacubex/tls v0.1.5/go.mod h1:0XeVdL0cBw+8i5Hqy3lVeP9IyD/LFTq02ExvHM6rzEM=
github.com/metacubex/tls v0.1.6 h1:t2ubLneYa4ceyIC++54a57BLqZFA/QYUrhdjLk2GPwo=
github.com/metacubex/tls v0.1.6/go.mod h1:0XeVdL0cBw+8i5Hqy3lVeP9IyD/LFTq02ExvHM6rzEM=
github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg=
github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko=
github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f h1:FGBPRb1zUabhPhDrlKEjQ9lgIwQ6cHL4x8M9lrERhbk=
@@ -175,6 +175,8 @@ github.com/pires/go-proxyproto v0.8.0 h1:5unRmEAPbHXHuLjDg01CxJWf91cw3lKHc/0xzKp
github.com/pires/go-proxyproto v0.8.0/go.mod h1:iknsfgnH8EkjrMeMyvfKByp9TiBZCKZM0jx2xmKqnVY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e h1:dCWirM5F3wMY+cmRda/B1BiPsFtmzXqV9b0hLWtVBMs=
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e/go.mod h1:9leZcVcItj6m9/CfHY5Em/iBrCz7js8LcRQGTKEEv2M=
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=

View File

@@ -87,7 +87,13 @@ typedef struct { void *data; GoInt len; GoInt cap; } GoSlice;
extern "C" {
#endif
// ConvertSubscription converts V2Ray subscription links to mihomo proxy configs
//
extern char* ConvertSubscription(char* data);
// FreeString frees memory allocated by Go (must be called from C++ after using the result)
//
extern void FreeString(char* s);
#ifdef __cplusplus

View File

@@ -1643,6 +1643,8 @@ public:
using Expect100ContinueHandler =
std::function<int(const Request &, Response &)>;
using StartHandler = std::function<void()>;
using WebSocketHandler =
std::function<void(const Request &, ws::WebSocket &)>;
using SubProtocolSelector =
@@ -1694,6 +1696,9 @@ public:
Server &set_pre_request_handler(HandlerWithResponse handler);
Server &set_expect_100_continue_handler(Expect100ContinueHandler handler);
Server &set_start_handler(StartHandler handler);
Server &set_logger(Logger logger);
Server &set_pre_compression_logger(Logger logger);
Server &set_error_logger(ErrorLogger error_logger);
@@ -1883,6 +1888,7 @@ private:
Handler post_routing_handler_;
HandlerWithResponse pre_request_handler_;
Expect100ContinueHandler expect_100_continue_handler_;
StartHandler start_handler_;
mutable std::mutex logger_mutex_;
Logger logger_;
@@ -3842,6 +3848,7 @@ public:
void set_socket_options(SocketOptions socket_options);
void set_connection_timeout(time_t sec, time_t usec = 0);
void set_interface(const std::string &intf);
void set_hostname_addr_map(std::map<std::string, std::string> addr_map);
#ifdef CPPHTTPLIB_SSL_ENABLED
void set_ca_cert_path(const std::string &path);
@@ -3876,6 +3883,9 @@ private:
time_t connection_timeout_usec_ = CPPHTTPLIB_CONNECTION_TIMEOUT_USECOND;
std::string interface_;
// Hostname-IP map
std::map<std::string, std::string> addr_map_;
#ifdef CPPHTTPLIB_SSL_ENABLED
bool is_ssl_ = false;
tls::ctx_t tls_ctx_ = nullptr;
@@ -4629,7 +4639,7 @@ inline std::string sha1(const std::string &input) {
// Pre-processing: adding padding bits
std::string msg = input;
uint64_t original_bit_len = static_cast<uint64_t>(msg.size()) * 8;
msg.push_back(static_cast<char>(0x80));
msg.push_back(static_cast<char>(0x80u));
while (msg.size() % 64 != 56) {
msg.push_back(0);
}
@@ -8443,6 +8453,14 @@ inline void coalesce_ranges(Ranges &ranges, size_t content_length) {
inline bool range_error(Request &req, Response &res) {
if (!req.ranges.empty() && 200 <= res.status && res.status < 300) {
if (res.body.empty() && res.content_provider_ && res.content_length_ == 0) {
req.ranges.clear();
if (res.status == StatusCode::PartialContent_206) {
res.status = StatusCode::OK_200;
}
return false;
}
ssize_t content_len = static_cast<ssize_t>(
res.content_length_ ? res.content_length_ : res.body.size());
@@ -11092,6 +11110,11 @@ Server::set_expect_100_continue_handler(Expect100ContinueHandler handler) {
return *this;
}
inline Server &Server::set_start_handler(StartHandler handler) {
start_handler_ = std::move(handler);
return *this;
}
inline Server &Server::set_address_family(int family) {
address_family_ = family;
return *this;
@@ -11787,6 +11810,8 @@ inline bool Server::listen_internal() {
is_running_ = true;
auto se = detail::scope_exit([&]() { is_running_ = false; });
if (start_handler_) { start_handler_(); }
{
std::unique_ptr<TaskQueue> task_queue(new_task_queue());
@@ -20305,9 +20330,14 @@ inline bool WebSocketClient::connect() {
if (!is_valid_) { return false; }
shutdown_and_close();
// Check is custom IP specified for host_
std::string ip;
auto it = addr_map_.find(host_);
if (it != addr_map_.end()) { ip = it->second; }
Error error;
sock_ = detail::create_client_socket(
host_, std::string(), port_, address_family_, tcp_nodelay_, ipv6_v6only_,
host_, ip, port_, address_family_, tcp_nodelay_, ipv6_v6only_,
socket_options_, connection_timeout_sec_, connection_timeout_usec_,
read_timeout_sec_, read_timeout_usec_, write_timeout_sec_,
write_timeout_usec_, interface_, error);
@@ -20402,6 +20432,11 @@ inline void WebSocketClient::set_interface(const std::string &intf) {
interface_ = intf;
}
inline void WebSocketClient::set_hostname_addr_map(
std::map<std::string, std::string> addr_map) {
addr_map_ = std::move(addr_map);
}
#ifdef CPPHTTPLIB_SSL_ENABLED
inline void WebSocketClient::set_ca_cert_path(const std::string &path) {

394
pref.toml Normal file
View File

@@ -0,0 +1,394 @@
version = 1
[common]
# API mode is hardcoded to true for security - cannot be configured
# Token authentication is disabled - users must provide url parameter
# Default URLs, used when no URL is provided in request, use "|" to separate multiple subscription links, supports local files/URL
default_url = []
# Insert subscription links to requests. Can be used to add node(s) to all exported subscriptions.
enable_insert = true
# URLs to insert before subscription links, can be used to add node(s) to all exported subscriptions, supports local files/URL
insert_url = [""]
# Prepend inserted URLs to subscription links. Nodes in insert_url will be added to groups first with non-group-specific match pattern.
prepend_insert_url = true
# Exclude nodes which remarks match the following patterns. Supports regular expression.
exclude_remarks = ["(到期|剩余流量|时间|官网|产品)"]
# Only include nodes which remarks match the following patterns. Supports regular expression.
#include_remarks = ["V3.*港"]
# Enable script support for filtering nodes
enable_filter = false
# Script used for filtering nodes. Supports inline script and script path. A "filter" function with 1 argument which is a node should be defined in the script.
# Example: Inline script: set value to content of script.
# Script path: set value to "path:/path/to/script.js".
#filter_script = '''
#function filter(node) {
# const info = JSON.parse(node.ProxyInfo);
# if(info.EncryptMethod.includes('chacha20'))
# return true;
# return false;
#}
#'''
default_external_config = "https://testingcf.jsdelivr.net/gh/Aethersailor/Custom_OpenClash_Rules@refs/heads/main/cfg/Custom_Clash.ini"
# The file scope limit of the 'rule_base' options in external configs.
base_path = "base"
# Clash config base used by the generator, supports local files/URL
clash_rule_base = "base/all_base.tpl"
# Surge config base used by the generator, supports local files/URL
surge_rule_base = "base/all_base.tpl"
# Surfboard config base used by the generator, supports local files/URL
surfboard_rule_base = "base/all_base.tpl"
# Mellow config base used by the generator, supports local files/URL
mellow_rule_base = "base/all_base.tpl"
# Quantumult config base used by the generator, supports local files/URL
quan_rule_base = "base/all_base.tpl"
# Quantumult X config base used by the generator, supports local files/URL
quanx_rule_base = "base/all_base.tpl"
# Loon config base used by the generator, supports local files/URL
loon_rule_base = "base/all_base.tpl"
# Shadowsocks Android config base used by the generator, supports local files/URL
sssub_rule_base = "base/all_base.tpl"
# sing-box config base used by the generator, supports local files/URL
singbox_rule_base = "base/all_base.tpl"
# Proxy used to download rulesets or subscriptions, set to NONE or empty to disable it, set to SYSTEM to use system proxy.
# Accept cURL-supported proxies (http:// https:// socks4a:// socks5://)
proxy_config = "SYSTEM"
proxy_ruleset = "SYSTEM"
proxy_subscription = "NONE"
# Append a proxy type string ([SS] [SSR] [VMess]) to node remark.
append_proxy_type = false
# When requesting /sub, reload this config file first.
reload_conf_on_request = false
[[userinfo.stream_rule]]
# Rules to extract stream data from node
# Format: full_match_regex|new_format_regex
# where new_format_regex should be like "total=$1&left=$2&used=$3"
match = '^剩余流量:(.*?)\|总流量:(.*)$'
replace = 'total=$2&left=$1'
[[userinfo.stream_rule]]
match = '^剩余流量:(.*?) (.*)$'
replace = 'total=$1&left=$2'
[[userinfo.stream_rule]]
match = '^Bandwidth: (.*?)/(.*)$'
replace = 'used=$1&total=$2'
[[userinfo.stream_rule]]
match = '^.*剩余(.*?)(?:\s*?)@(?:.*)$'
replace = 'total=$1'
[[userinfo.time_rule]]
# Rules to extract expire time data from node
# Format: full_match_regex|new_format_regex
# where new_format_regex should follow this example: yyyy:mm:dd:hh:mm:ss
match = '^过期时间:(\d+)-(\d+)-(\d+) (\d+):(\d+):(\d+)$'
replace = '$1:$2:$3:$4:$5:$6'
[[userinfo.time_rule]]
match = '^到期时间:(\d+)-(\d+)-(\d+)$'
replace = '$1:$2:$3:0:0:0'
[[userinfo.time_rule]]
match = '^Smart Access expire: (\d+)/(\d+)/(\d+)$'
replace = '$1:$2:$3:0:0:0'
[node_pref]
#udp_flag = false
#tcp_fast_open_flag = false
#skip_cert_verify_flag = false
#tls13_flag = false
sort_flag = false
# Script used for sorting nodes. A "compare" function with 2 arguments which are the 2 nodes to be compared should be defined in the script. Supports inline script and script path.
# Examples can be seen at the filter_script option in [common] section.
#sort_script = '''
#function compare(node_a, node_b) {
# return info_a.Remark > info_b.Remark;
#}
#'''
filter_deprecated_nodes = false
append_sub_userinfo = true
clash_use_new_field_name = true
# Generate style of the proxies and proxy groups section of Clash subscriptions.
# Supported styles: block, flow, compact
# Block: - name: name1 Flow: - {name: name1, key: value} Compact: [{name: name1, key: value},{name: name2, key: value}]
# key: value - {name: name2, key: value}
# - name: name2
# key: value
clash_proxies_style = "flow"
clash_proxy_groups_style = "flow"
# add Clash mode to sing-box rules, and add a GLOBAL group to end of outbounds
singbox_add_clash_modes = true
[[node_pref.rename_node]]
match = '\(?((x|X)?(\d+)(\.?\d+)?)((\s?倍率?)|(x|X))\)?'
replace = "$1x"
[managed_config]
# Append a '#!MANAGED-CONFIG' info to Surge configurations
write_managed_config = true
# Address prefix for MANAGED-CONFIG info, without the trailing "/".
managed_config_prefix = "http://127.0.0.1:25500"
# Managed config update interval in seconds, determine how long the config will be updated.
config_update_interval = 86400
# If config_update_strict is set to true, Surge will require a force update after the interval.
config_update_strict = false
# Device ID to be written to rewrite scripts for some version of Quantumult X
quanx_device_id = ""
[surge_external_proxy]
#surge_ssr_path = "/usr/bin/ssr-local"
resolve_hostname = true
[security]
# Security profile:
# lan - default, legacy behavior for private/LAN deployments. Local, private
# and fake-ip resources are allowed.
# public - for Internet-facing deployments. Only untrusted request-controlled
# fetches are restricted; built-in local templates and trusted config
# files continue to work.
# strict - same public fetch restrictions, and public upload cannot be enabled.
# Environment override: SUBCONVERTER_SECURITY_PROFILE=lan|public|strict
profile = "lan"
# Only used by public profile. lan keeps legacy upload behavior; strict always
# disables public upload.
# Environment override: SUBCONVERTER_ALLOW_PUBLIC_UPLOAD=true|false
allow_public_upload = false
[statistics]
# Opt-in runtime statistics and /dashboard. Missing or false keeps it disabled
# and avoids registering the dashboard or statistics-enabled request handler.
enabled = false
# Put this directory on a Docker volume if statistics should survive restarts.
data_dir = "stats"
# Minimum seconds between persistence writes.
flush_interval = 5
[statistics.geo]
# header uses country-only headers such as CF-IPCountry and never stores IPs.
# none records all countries as unknown.
provider = "header"
country_headers = ["CF-IPCountry", "X-Geo-Country", "X-Vercel-IP-Country", "CloudFront-Viewer-Country"]
[statistics.dashboard_auth]
# Optional Basic authentication for /dashboard and /dashboard/data.
# Only applies when statistics.enabled is true.
# Missing or false keeps the dashboard password disabled.
enabled = false
username = ""
password = ""
# Failed login attempts allowed within window_seconds before lock_seconds applies.
max_failures = 5
window_seconds = 300
lock_seconds = 900
[emojis]
add_emoji = false
remove_old_emoji = true
[[emojis.emoji]]
#match = '(流量|时间|应急)'
#emoji = '🏳️‍🌈'
import = "snippets/emoji.toml"
# [[custom_groups]]
# name = "Auto"
# type = "url-test"
# rule = [".*"]
# url = "http://www.gstatic.com/generate_204"
# interval = 300
# tolerance = 150
# lazy = true
# [[custom_groups]]
# name = "Proxy"
# type = "select"
# rule = [".*", "[]DIRECT"]
# disable_udp = false
# [[custom_groups]]
# name = "LoadBalance"
# type = "load-balance"
# rule = [".*", "[]Proxy", "[]DIRECT"]
# interval = 100
# strategy = "consistent-hashing"
# url = "http://www.gstatic.com/generate_204"
[[custom_groups]]
import = "snippets/groups.toml"
[ruleset]
# Enable generating rules with rulesets
enabled = true
# Overwrite the existing rules in rule_base
overwrite_original_rules = false
# Perform a ruleset update on request
update_ruleset_on_request = false
# [[rulesets]]
# group = "Proxy"
# ruleset = "https://raw.githubusercontent.com/DivineEngine/Profiles/master/Surge/Ruleset/Unbreak.list"
# type = "surge-ruleset"
# interval = 86400
[[rulesets]]
import = "snippets/rulesets.toml"
[template]
template_path = ""
[[template.globals]]
key = "clash.http_port"
value = "7890"
[[template.globals]]
key = "clash.socks_port"
value = "7891"
[[template.globals]]
key = "clash.allow_lan"
value = "true"
[[template.globals]]
key = "clash.log_level"
value = "info"
[[template.globals]]
key = "clash.external_controller"
value = "127.0.0.1:9090"
[[template.globals]]
key = "singbox.allow_lan"
value = "true"
[[template.globals]]
key = "singbox.mixed_port"
value = "2080"
[[aliases]]
uri = "/clash"
target = "/sub?target=clash"
[[aliases]]
uri = "/clashr"
target = "/sub?target=clashr"
[[aliases]]
uri = "/surge"
target = "/sub?target=surge"
[[aliases]]
uri = "/quan"
target = "/sub?target=quan"
[[aliases]]
uri = "/quanx"
target = "/sub?target=quanx"
[[aliases]]
uri = "/mellow"
target = "/sub?target=mellow"
[[aliases]]
uri = "/surfboard"
target = "/sub?target=surfboard"
[[aliases]]
uri = "/loon"
target = "/sub?target=loon"
[[aliases]]
uri = "/singbox"
target = "/sub?target=singbox"
[[aliases]]
uri = "/ss"
target = "/sub?target=ss"
[[aliases]]
uri = "/ssd"
target = "/sub?target=ssd"
[[aliases]]
uri = "/sssub"
target = "/sub?target=sssub"
[[aliases]]
uri = "/ssr"
target = "/sub?target=ssr"
[[aliases]]
uri = "/v2ray"
target = "/sub?target=v2ray"
[[aliases]]
uri = "/trojan"
target = "/sub?target=trojan"
[[aliases]]
uri = "/test"
target = "/render?path=templates/test.tpl"
#[[tasks]]
#name = "tick"
#cronexp = "0/10 * * * * ?"
#path = "tick.js"
#timeout = 3
[server]
listen = "0.0.0.0"
port = 25500
serve_file_root = "web"
[advanced]
log_level = "info"
print_debug_info = false
max_pending_connections = 10240
max_concurrent_threads = 16
# Maximum HTTP worker threads during bursts. Requests above this limit remain
# queued instead of being rejected.
max_server_threads = 128
max_allowed_rulesets = 64
max_allowed_rules = 0
max_allowed_download_size = 0
enable_cache = true
cache_subscription = 60
cache_config = 300
cache_ruleset = 21600
script_clean_context = true
async_fetch_ruleset = true
skip_failed_links = true
enable_request_coalescing = true
coalesce_retry_on_5xx = true
# 0 disables completed response caching. If enabled, values above 5 seconds are clamped to 5.
response_cache_ttl = 0

View File

@@ -12,6 +12,7 @@ from __future__ import annotations
import argparse
import difflib
import json
import re
import sys
import urllib.error
import urllib.parse
@@ -29,12 +30,22 @@ def build_url(base_url: str, path: str, params: dict[str, str] | None = None) ->
return f"{base}{path}" + (f"?{query}" if query else "")
def fetch(base_url: str, path: str, params: dict[str, str] | None, timeout: int) -> str:
def fetch_response(
base_url: str,
path: str,
params: dict[str, str] | None,
timeout: int,
headers: dict[str, str] | None = None,
) -> tuple[str, dict[str, str]]:
url = build_url(base_url, path, params)
request = urllib.request.Request(url, headers=headers or {})
try:
with urllib.request.urlopen(url, timeout=timeout) as response:
with urllib.request.urlopen(request, timeout=timeout) as response:
status = response.status
body = response.read().decode("utf-8", errors="replace")
response_headers = {
key.lower(): value for key, value in response.headers.items()
}
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise AssertionError(f"{url} returned HTTP {exc.code}\n{body}") from exc
@@ -43,6 +54,11 @@ def fetch(base_url: str, path: str, params: dict[str, str] | None, timeout: int)
if status < 200 or status >= 300:
raise AssertionError(f"{url} returned HTTP {status}\n{body}")
return body, response_headers
def fetch(base_url: str, path: str, params: dict[str, str] | None, timeout: int) -> str:
body, _ = fetch_response(base_url, path, params, timeout)
return body
@@ -76,6 +92,75 @@ def run_checks(base_url: str, timeout: int, snapshot_dir: Path | None, update: b
if health.strip() != "ok":
raise AssertionError(f"/healthz returned unexpected body: {health!r}")
version_page, version_headers = fetch_response(
base_url, "/version", None, timeout
)
if (
"<!DOCTYPE html>" not in version_page
or "SubConverter-Extended" not in version_page
):
raise AssertionError("/version did not return the HTML version page")
if not version_headers.get("content-type", "").lower().startswith("text/html"):
raise AssertionError("/version HTML response has an unexpected content type")
navigation_page, navigation_headers = fetch_response(
base_url,
"/version",
None,
timeout,
{
"Origin": "https://edgetunnel.example",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Dest": "document",
},
)
if "<!DOCTYPE html>" not in navigation_page:
raise AssertionError("/version navigation request did not return HTML")
if not navigation_headers.get("content-type", "").lower().startswith(
"text/html"
):
raise AssertionError("/version navigation response has an unexpected content type")
probe_headers = {
"Origin": "https://edgetunnel.example",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Dest": "empty",
}
version_probe, version_probe_headers = fetch_response(
base_url, "/version", None, timeout, probe_headers
)
version_probe_line = version_probe.strip()
if not re.fullmatch(
r"SubConverter-Extended \S+ backend", version_probe_line
):
raise AssertionError(
f"/version probe returned an unexpected body: {version_probe!r}"
)
if "subconverter" not in version_probe_line.lower() or "<" in version_probe_line:
raise AssertionError("/version probe is not compatible with backend detection")
if not version_probe_headers.get("content-type", "").lower().startswith(
"text/plain"
):
raise AssertionError("/version probe response has an unexpected content type")
if version_probe_headers.get("access-control-allow-origin") != "*":
raise AssertionError("/version probe response is missing the CORS header")
if "no-store" not in version_probe_headers.get("cache-control", "").lower():
raise AssertionError("/version probe response is missing no-store caching")
vary = version_probe_headers.get("vary", "").lower()
for header in ("sec-fetch-mode", "sec-fetch-dest", "origin"):
if header not in vary:
raise AssertionError(f"/version probe Vary header is missing {header}")
legacy_probe, _ = fetch_response(
base_url,
"/version",
None,
timeout,
{"Origin": "https://edgetunnel.example"},
)
if legacy_probe != version_probe:
raise AssertionError("/version legacy browser probe response is inconsistent")
inspect_page = fetch(base_url, "/inspect", None, timeout)
if (
"Request Inspector" not in inspect_page

View File

@@ -144,14 +144,11 @@ std::string page(Request &request, Response &response) {
body {
font-family: 'Outfit', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
margin: 0;
min-height: 100vh;
min-height: 100svh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-gradient);
background-attachment: fixed;
padding: 24px;
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -182,38 +179,82 @@ std::string page(Request &request, Response &response) {
opacity: 0.82;
}
.lang-toggle {
position: fixed;
top: calc(18px + env(safe-area-inset-top, 0px));
right: calc(18px + env(safe-area-inset-right, 0px));
z-index: 10;
display: inline-flex;
.shell {
position: relative;
z-index: 1;
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 42px;
}
.topbar {
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.brand-row {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.brand-row img {
width: 48px;
height: 48px;
flex: 0 0 auto;
filter: drop-shadow(0 12px 24px rgba(2, 132, 199, 0.16));
}
.brand-row h1 {
margin: 0;
font-size: 1.8rem;
line-height: 1.08;
letter-spacing: 0;
overflow-wrap: anywhere;
background: none;
-webkit-background-clip: unset;
background-clip: unset;
-webkit-text-fill-color: unset;
}
.brand-row .top-subtitle {
margin-top: 5px;
color: var(--text-secondary);
font-size: 0.94rem;
font-weight: 600;
}
.actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.lang-btn {
border: 1px solid var(--control-border);
border-radius: 999px;
background: var(--control-bg);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
box-shadow: var(--control-shadow);
color: var(--text-primary);
cursor: pointer;
font: inherit;
font-size: 0.86rem;
font-size: 0.88rem;
font-weight: 700;
line-height: 1;
min-height: 40px;
min-width: 76px;
padding: 9px 13px;
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.lang-toggle:hover {
.lang-btn:hover {
background: var(--control-hover);
transform: translateY(-1px);
}
.lang-btn:focus-visible {
outline: 3px solid rgba(99, 179, 237, 0.35);
outline-offset: 2px;
}
.lang-toggle:focus-visible,
button:focus-visible,
textarea:focus-visible,
input:focus-visible {
@@ -221,17 +262,6 @@ std::string page(Request &request, Response &response) {
outline-offset: 2px;
}
.lang-toggle svg {
width: 17px;
height: 17px;
flex: 0 0 auto;
}
.lang-toggle-text {
min-width: 20px;
text-align: center;
}
.page-links {
display: inline-flex;
justify-content: center;
@@ -747,9 +777,14 @@ std::string page(Request &request, Response &response) {
@media (max-width: 780px) {
body {
align-items: stretch;
padding: 76px 14px 18px;
padding: 0;
}
.shell {
padding: 16px 0 24px;
width: min(100% - 20px, 1180px);
}
.topbar { flex-direction: column; align-items: flex-start; }
.brand-row h1 { font-size: 1.4rem; }
.container {
border-radius: 24px;
@@ -827,17 +862,22 @@ std::string page(Request &request, Response &response) {
</style>
</head>
<body>
<button class="lang-toggle" type="button" aria-label="Switch language">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 5h9M9 3v2m1.7 0c-.6 3.5-2.4 6.1-5.2 7.7m2.8-3.1c1.1 1.3 2.3 2.3 3.7 3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 20l4-9 4 9m-6.7-3h5.4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="lang-toggle-text" data-lang="en">中</span>
<span class="lang-toggle-text" data-lang="zh">EN</span>
</button>
<main class="container">
<header>
<div class="shell">
<div class="topbar">
<div class="brand-row">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="/version/favicon-dark.svg">
<img src="/version/favicon-light.svg" alt="SubConverter-Extended" width="48" height="48" decoding="async">
</picture>
<div>
<h1>SubConverter-Extended</h1>
<div class="top-subtitle">
<span data-lang="en">Request Inspector</span>
<span data-lang="zh"></span>
</div>
</div>
</div>
<div class="actions">
<picture class="brand-mark">
<source media="(prefers-color-scheme: dark)" srcset="/version/favicon-dark.svg">
<img src="/version/favicon-light.svg" alt="SubConverter-Extended icon" width="88" height="88" decoding="async">
@@ -865,8 +905,13 @@ std::string page(Request &request, Response &response) {
<span data-lang="zh"></span>
</a>)html" +
dashboard_link + R"html(
</nav>
</header>
<button type="button" class="lang-btn" id="lang-toggle" aria-label="Switch language">
<span class="lang-toggle-text">中</span>
</button>
</div>
</div>
<main class="container">
<header>
<section class="section">
<div class="section-title">
@@ -1036,7 +1081,7 @@ std::string page(Request &request, Response &response) {
R"html( · <a href="/version">版本信息</a> · 源代码:<a href="https://github.com/Aethersailor/SubConverter-Extended" target="_blank" rel="noopener noreferrer">GitHub</a> · 许可证:<a href="https://www.gnu.org/licenses/gpl-3.0.html" target="_blank" rel="noopener noreferrer">GPL-3.0</a></span>
</footer>
</main>
</div>
<script>
(function () {
var input = document.getElementById("request-input");
@@ -1502,7 +1547,7 @@ std::string page(Request &request, Response &response) {
}
}
document.querySelector(".lang-toggle").addEventListener("click", function () {
document.getElementById("lang-toggle").addEventListener("click", function () {
document.documentElement.lang = isZh() ? "en" : "zh-CN";
if (lastReport) {
renderReport(lastReport);

View File

@@ -3,6 +3,7 @@
#include <string>
#include "handler/settings.h"
#include "utils/string.h"
#include "version.h"
namespace {
@@ -88,6 +89,32 @@ std::string buildCommitLink(const std::string &build_id) {
build_id + "</a>";
}
std::string headerValue(const Request &request, const std::string &name) {
auto iter = request.headers.find(name);
if (iter == request.headers.end())
return "";
return trimWhitespace(iter->second, true, true);
}
bool isScriptVersionProbe(const Request &request) {
std::string fetch_mode = toLower(headerValue(request, "Sec-Fetch-Mode"));
std::string fetch_dest = toLower(headerValue(request, "Sec-Fetch-Dest"));
if (fetch_mode == "cors" && fetch_dest == "empty")
return true;
return fetch_mode.empty() && fetch_dest.empty() &&
!headerValue(request, "Origin").empty();
}
std::string buildPlainVersion() {
std::string version = VERSION;
std::string build_id = BUILD_ID;
if (!build_id.empty())
version += "-" + build_id;
return "SubConverter-Extended " + version + " backend\n";
}
} // namespace
namespace version_page {
@@ -102,9 +129,16 @@ std::string faviconLight(Request &, Response &response) {
return VERSION_FAVICON_LIGHT;
}
std::string page(Request &, Response &response) {
std::string page(Request &request, Response &response) {
response.headers["X-Robots-Tag"] =
"noindex, nofollow, noarchive, nosnippet, noimageindex";
response.headers["Vary"] = "Sec-Fetch-Mode, Sec-Fetch-Dest, Origin";
if (isScriptVersionProbe(request)) {
response.content_type = "text/plain; charset=utf-8";
response.headers["Cache-Control"] = "no-store";
return buildPlainVersion();
}
std::string build_id = BUILD_ID;
std::string build_date = BUILD_DATE;
std::string build_date_display = formatBuildDate(build_date);
@@ -236,14 +270,11 @@ std::string page(Request &, Response &response) {
body {
font-family: 'Outfit', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
margin: 0;
min-height: 100vh;
min-height: 100svh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-gradient);
background-attachment: fixed;
padding: 24px;
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -274,53 +305,82 @@ std::string page(Request &, Response &response) {
opacity: 0.82;
}
.lang-toggle {
position: fixed;
top: calc(18px + env(safe-area-inset-top, 0px));
right: calc(18px + env(safe-area-inset-right, 0px));
z-index: 10;
display: inline-flex;
.shell {
position: relative;
z-index: 1;
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 42px;
}
.topbar {
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.brand-row {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.brand-row img {
width: 48px;
height: 48px;
flex: 0 0 auto;
filter: drop-shadow(0 12px 24px rgba(2, 132, 199, 0.16));
}
.brand-row h1 {
margin: 0;
font-size: 1.8rem;
line-height: 1.08;
letter-spacing: 0;
overflow-wrap: anywhere;
background: none;
-webkit-background-clip: unset;
background-clip: unset;
-webkit-text-fill-color: unset;
}
.brand-row .top-subtitle {
margin-top: 5px;
color: var(--text-secondary);
font-size: 0.94rem;
font-weight: 600;
}
.actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.lang-btn {
border: 1px solid var(--control-border);
border-radius: 999px;
background: var(--control-bg);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
box-shadow: var(--control-shadow);
color: var(--text-primary);
cursor: pointer;
font: inherit;
font-size: 0.86rem;
font-size: 0.88rem;
font-weight: 700;
line-height: 1;
min-height: 40px;
min-width: 76px;
padding: 9px 13px;
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.lang-toggle:hover {
.lang-btn:hover {
background: var(--control-hover);
transform: translateY(-1px);
}
.lang-toggle:focus-visible {
.lang-btn:focus-visible {
outline: 3px solid rgba(99, 179, 237, 0.35);
outline-offset: 2px;
}
.lang-toggle svg {
width: 17px;
height: 17px;
flex: 0 0 auto;
}
.lang-toggle-text {
min-width: 20px;
text-align: center;
}
.page-links {
display: inline-flex;
justify-content: center;
@@ -706,12 +766,12 @@ std::string page(Request &, Response &response) {
.container::after {
border-radius: 26px;
}
.lang-toggle {
top: calc(14px + env(safe-area-inset-top, 0px));
right: calc(14px + env(safe-area-inset-right, 0px));
min-height: 38px;
min-width: 70px;
.shell {
padding: 16px 0 24px;
width: min(100% - 20px, 1180px);
}
.topbar { flex-direction: column; align-items: flex-start; }
.brand-row h1 { font-size: 1.4rem; }
header { margin-bottom: 24px; }
h1 {
font-size: 2em;
@@ -762,17 +822,22 @@ std::string page(Request &, Response &response) {
</style>
</head>
<body>
<button type="button" class="lang-toggle" id="lang-toggle">
<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<path d="M2 12h20"></path>
<path d="M12 2a15.3 15.3 0 0 1 0 20"></path>
<path d="M12 2a15.3 15.3 0 0 0 0 20"></path>
</svg>
<span class="lang-toggle-text">中</span>
</button>
<div class="container">
<header>
<main class="shell">
<div class="topbar">
<div class="brand-row">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="/version/favicon-dark.svg">
<img src="/version/favicon-light.svg" alt="SubConverter-Extended" width="48" height="48" decoding="async">
</picture>
<div>
<h1>SubConverter-Extended</h1>
<div class="top-subtitle">
<span data-lang="en">A Modern Evolution of Subconverter</span>
<span data-lang="zh">Subconverter </span>
</div>
</div>
</div>
<div class="actions">
<picture class="brand-mark">
<source media="(prefers-color-scheme: dark)" srcset="/version/favicon-dark.svg">
<img src="/version/favicon-light.svg" alt="SubConverter-Extended icon" width="96" height="96" decoding="async">
@@ -799,8 +864,13 @@ std::string page(Request &, Response &response) {
<span data-lang="zh"></span>
</a>)html" +
dashboard_link + R"html(
</nav>
</header>
<button type="button" class="lang-btn" id="lang-toggle" aria-label="Switch language">
<span class="lang-toggle-text">中</span>
</button>
</div>
</div>
<div class="container">
<header>
<div class="info-grid">
<div class="info-card">
@@ -879,6 +949,7 @@ std::string page(Request &, Response &response) {
<span data-lang="zh"><a href="https://github.com/Aethersailor/SubConverter-Extended" target="_blank" rel="noopener noreferrer">GitHub</a> <a href="https://www.gnu.org/licenses/gpl-3.0.html" target="_blank" rel="noopener noreferrer">GPL-3.0</a></span>
</div>
</div>
</main>
<script>
(function () {
var toggle = document.getElementById("lang-toggle");

1046
src/handler/webapp_page.cpp Normal file

File diff suppressed because it is too large Load Diff

14
src/handler/webapp_page.h Normal file
View File

@@ -0,0 +1,14 @@
#ifndef WEBAPP_PAGE_H_INCLUDED
#define WEBAPP_PAGE_H_INCLUDED
#include <string>
#include "server/webserver.h"
namespace webapp_page {
std::string page(Request &, Response &response);
} // namespace webapp_page
#endif // WEBAPP_PAGE_H_INCLUDED

View File

@@ -15,6 +15,7 @@
#include "handler/statistics.h"
#include "handler/version_page.h"
#include "handler/webget.h"
#include "handler/webapp_page.h"
#include "script/cron.h"
#include "server/socket.h"
#include "server/webserver.h"
@@ -184,13 +185,8 @@ int main(int argc, char *argv[]) {
if (global.generatorMode)
return simpleGenerator();
/*
webServer.append_response("GET", "/", "text/plain", [](RESPONSE_CALLBACK_ARGS)
-> std::string
{
return "SubConverter-Extended " VERSION " backend\n";
});
*/
webServer.append_response("GET", "/", "text/html; charset=utf-8",
webapp_page::page);
webServer.append_response("GET", "/version/favicon-dark.svg",
"image/svg+xml; charset=utf-8",