├── .github
└── workflows
│ └── main.yml
├── .gitignore
├── .gitmodules
├── LICENSE
├── README.md
├── backend
├── .babelrc
├── .eslintrc.json
├── .prettierrc.json
├── banner
├── bundle-esbuild.js
├── bundle.js
├── dev-esbuild.js
├── dist
│ └── .gitkeep
├── gulpfile.babel.js
├── jsconfig.json
├── package.json
├── patches
│ └── http-proxy@1.18.1.patch
├── pnpm-lock.yaml
├── src
│ ├── constants.js
│ ├── core
│ │ ├── app.js
│ │ ├── proxy-utils
│ │ │ ├── index.js
│ │ │ ├── parsers
│ │ │ │ ├── index.js
│ │ │ │ └── peggy
│ │ │ │ │ ├── loon.js
│ │ │ │ │ ├── loon.peg
│ │ │ │ │ ├── qx.js
│ │ │ │ │ ├── qx.peg
│ │ │ │ │ ├── surge.js
│ │ │ │ │ ├── surge.peg
│ │ │ │ │ ├── trojan-uri.js
│ │ │ │ │ └── trojan-uri.peg
│ │ │ ├── preprocessors
│ │ │ │ └── index.js
│ │ │ ├── processors
│ │ │ │ └── index.js
│ │ │ ├── producers
│ │ │ │ ├── clash.js
│ │ │ │ ├── clashmeta.js
│ │ │ │ ├── egern.js
│ │ │ │ ├── index.js
│ │ │ │ ├── loon.js
│ │ │ │ ├── qx.js
│ │ │ │ ├── shadowrocket.js
│ │ │ │ ├── sing-box.js
│ │ │ │ ├── stash.js
│ │ │ │ ├── surfboard.js
│ │ │ │ ├── surge.js
│ │ │ │ ├── surgemac.js
│ │ │ │ ├── uri.js
│ │ │ │ ├── utils.js
│ │ │ │ └── v2ray.js
│ │ │ └── validators
│ │ │ │ └── index.js
│ │ └── rule-utils
│ │ │ ├── index.js
│ │ │ ├── parsers.js
│ │ │ ├── preprocessors.js
│ │ │ └── producers.js
│ ├── main.js
│ ├── products
│ │ ├── cron-sync-artifacts.js
│ │ ├── resource-parser.loon.js
│ │ ├── sub-store-0.js
│ │ └── sub-store-1.js
│ ├── restful
│ │ ├── artifacts.js
│ │ ├── collections.js
│ │ ├── download.js
│ │ ├── errors
│ │ │ └── index.js
│ │ ├── file.js
│ │ ├── index.js
│ │ ├── miscs.js
│ │ ├── module.js
│ │ ├── node-info.js
│ │ ├── parser.js
│ │ ├── preview.js
│ │ ├── response.js
│ │ ├── settings.js
│ │ ├── sort.js
│ │ ├── subscriptions.js
│ │ ├── sync.js
│ │ └── token.js
│ ├── test
│ │ └── proxy-parsers
│ │ │ ├── loon.spec.js
│ │ │ ├── qx.spec.js
│ │ │ ├── surge.spec.js
│ │ │ └── testcases.js
│ ├── utils
│ │ ├── database.js
│ │ ├── dns.js
│ │ ├── download.js
│ │ ├── env.js
│ │ ├── flow.js
│ │ ├── geo.js
│ │ ├── gist.js
│ │ ├── headers-resource-cache.js
│ │ ├── index.js
│ │ ├── logical.js
│ │ ├── migration.js
│ │ ├── resource-cache.js
│ │ ├── rs.js
│ │ ├── script-resource-cache.js
│ │ ├── user-agent.js
│ │ └── yaml.js
│ └── vendor
│ │ ├── express.js
│ │ ├── md5.js
│ │ └── open-api.js
└── sub-store_1748083027961.json
├── config
├── Egern.yaml
├── Loon.plugin
├── QX-Task.json
├── QX.snippet
├── README.md
├── Stash.stoverride
├── Surge-Beta.sgmodule
├── Surge-Noability.sgmodule
├── Surge-ability.sgmodule
└── Surge.sgmodule
├── nginx
└── front.conf
├── scripts
├── demo.js
├── fancy-characters.js
├── ip-flag-node.js
├── ip-flag.js
├── media-filter.js
├── revert.js
├── tls-fingerprint.js
├── udp-filter.js
└── vmess-ws-obfs-host.js
├── support.nodeseek.com_page_promotion_id=8.png
└── vs.code-workspace
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: build
2 | on:
3 | workflow_dispatch:
4 | push:
5 | branches:
6 | - master
7 | paths:
8 | - "backend/package.json"
9 | pull_request:
10 | branches:
11 | - master
12 | paths:
13 | - "backend/package.json"
14 | jobs:
15 | build:
16 | runs-on: ubuntu-latest
17 | steps:
18 | - name: Checkout
19 | uses: actions/checkout@v3
20 | with:
21 | ref: "master"
22 | - name: Set up Node.js
23 | uses: actions/setup-node@v3
24 | with:
25 | node-version: "20"
26 | - name: Install dependencies
27 | run: |
28 | npm install -g pnpm
29 | cd backend && pnpm i --no-frozen-lockfile
30 | # - name: Test
31 | # run: |
32 | # cd backend
33 | # pnpm test
34 | # - name: Build
35 | # run: |
36 | # cd backend
37 | # pnpm run build
38 | - name: Bundle
39 | run: |
40 | cd backend
41 | pnpm bundle:esbuild
42 | - id: tag
43 | name: Generate release tag
44 | run: |
45 | cd backend
46 | SUBSTORE_RELEASE=`node --eval="process.stdout.write(require('./package.json').version)"`
47 | echo "release_tag=$SUBSTORE_RELEASE" >> $GITHUB_OUTPUT
48 | - name: Prepare release
49 | run: |
50 | cd backend
51 | pnpm i -D conventional-changelog-cli
52 | pnpm run changelog
53 | - name: Release
54 | uses: softprops/action-gh-release@v1
55 | if: ${{ success() }}
56 | env:
57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
58 | with:
59 | body_path: ./backend/CHANGELOG.md
60 | tag_name: ${{ steps.tag.outputs.release_tag }}
61 | # generate_release_notes: true
62 | files: |
63 | ./backend/sub-store.min.js
64 | ./backend/dist/sub-store-0.min.js
65 | ./backend/dist/sub-store-1.min.js
66 | ./backend/dist/sub-store-parser.loon.min.js
67 | ./backend/dist/cron-sync-artifacts.min.js
68 | ./backend/dist/sub-store.bundle.js
69 | - name: Git push assets to "release" branch
70 | run: |
71 | cd backend/dist || exit 1
72 | git init
73 | git config --local user.name "github-actions[bot]"
74 | git config --local user.email "github-actions[bot]@users.noreply.github.com"
75 | git checkout -b release
76 | git add .
77 | git commit -m "release: ${{ steps.tag.outputs.release_tag }}"
78 | git remote add origin "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}"
79 | git push -f -u origin release
80 | # - name: Sync to GitLab
81 | # env:
82 | # GITLAB_PIPELINE_TOKEN: ${{ secrets.GITLAB_PIPELINE_TOKEN }}
83 | # run: |
84 | # curl -X POST --fail -F token=$GITLAB_PIPELINE_TOKEN -F ref=master https://gitlab.com/api/v4/projects/48891296/trigger/pipeline
85 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | # json config
3 | sub-store.json
4 | root.json
5 |
6 | # Logs
7 | logs
8 | *.log
9 | npm-debug.log*
10 | yarn-debug.log*
11 | yarn-error.log*
12 | lerna-debug.log*
13 |
14 | # Diagnostic reports (https://nodejs.org/api/report.html)
15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
16 |
17 | # Runtime data
18 | pids
19 | *.pid
20 | *.seed
21 | *.pid.lock
22 |
23 | # Directory for instrumented libs generated by jscoverage/JSCover
24 | lib-cov
25 |
26 | # Coverage directory used by tools like istanbul
27 | coverage
28 | *.lcov
29 |
30 | # nyc test coverage
31 | .nyc_output
32 |
33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
34 | .grunt
35 |
36 | # Bower dependency directory (https://bower.io/)
37 | bower_components
38 |
39 | # node-waf configuration
40 | .lock-wscript
41 |
42 | # Compiled binary addons (https://nodejs.org/api/addons.html)
43 | build/Release
44 |
45 | # Dependency directories
46 | node_modules/
47 | jspm_packages/
48 |
49 | # Snowpack dependency directory (https://snowpack.dev/)
50 | web_modules/
51 |
52 | # TypeScript cache
53 | *.tsbuildinfo
54 |
55 | # Optional npm cache directory
56 | .npm
57 |
58 | # Optional eslint cache
59 | .eslintcache
60 |
61 | # Microbundle cache
62 | .rpt2_cache/
63 | .rts2_cache_cjs/
64 | .rts2_cache_es/
65 | .rts2_cache_umd/
66 |
67 | # Optional REPL history
68 | .node_repl_history
69 |
70 | # Output of 'npm pack'
71 | *.tgz
72 |
73 | # Yarn Integrity file
74 | .yarn-integrity
75 |
76 | # dotenv environment variables file
77 | .env
78 | .env.test
79 |
80 | # parcel-bundler cache (https://parceljs.org/)
81 | .cache
82 | .parcel-cache
83 |
84 | # Next.js build output
85 | .next
86 | out
87 |
88 | # Nuxt.js build / generate output
89 | .nuxt
90 | # dist
91 |
92 | # Gatsby files
93 | .cache/
94 | # Comment in the public line in if your project uses Gatsby and not Next.js
95 | # https://nextjs.org/blog/next-9-1#public-directory-support
96 | # public
97 |
98 | # vuepress build output
99 | .vuepress/dist
100 |
101 | # Serverless directories
102 | .serverless/
103 |
104 | # FuseBox cache
105 | .fusebox/
106 |
107 | # DynamoDB Local files
108 | .dynamodb/
109 |
110 | # TernJS port file
111 | .tern-port
112 |
113 | # Stores VSCode versions used for testing VSCode extensions
114 | .vscode-test
115 |
116 | # yarn v2
117 | .yarn/cache
118 | .yarn/unplugged
119 | .yarn/build-state.yml
120 | .yarn/install-state.gz
121 | .pnp.*
122 |
123 | # Editor directories and files
124 | .idea
125 | .vscode
126 | *.suo
127 | *.ntvs*
128 | *.njsproj
129 | *.sln
130 | *.sw?
131 |
132 | # Dist files
133 | backend/dist/*
134 | !backend/dist/.gitkeep
135 | backend/sub-store.min.js
136 |
137 | CHANGELOG.md
138 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sub-store-org/Sub-Store/4aafdaaddbc7ba24651501eaaa25da69219084d9/.gitmodules
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |

4 |
5 |
6 |
Sub-Store
7 |
8 |
9 |
10 | Advanced Subscription Manager for QX, Loon, Surge, Stash, Egern and Shadowrocket.
11 |
12 |
13 | [](https://github.com/sub-store-org/Sub-Store/actions/workflows/main.yml)     
14 |
15 | [](https://www.buymeacoffee.com/PengYM)
16 |
17 | Core functionalities:
18 |
19 | 1. Conversion among various formats.
20 | 2. Subscription formatting.
21 | 3. Collect multiple subscriptions in one URL.
22 |
23 | > The following descriptions of features may not be updated in real-time. Please refer to the actual available features for accurate information.
24 |
25 | ## 1. Subscription Conversion
26 |
27 | ### Supported Input Formats
28 |
29 | > ⚠️ Do not use `Shadowrocket` or `NekoBox` to export URI and then import it as input. The URIs exported in this way may not be standard URIs.
30 |
31 | - [x] Proxy URI Scheme(`socks5`, `socks5+tls`, `http`, `https`(it's ok))
32 |
33 | example: `socks5+tls://user:pass@ip:port#name`
34 |
35 | - [x] URI(AnyTLS, SOCKS, SS, SSR, VMess, VLESS, Trojan, Hysteria, Hysteria 2, TUIC v5, WireGuard)
36 | - [x] Clash Proxies YAML
37 | - [x] Clash Proxy JSON(single line)
38 | - [x] QX (SS, SSR, VMess, Trojan, HTTP, SOCKS5, VLESS)
39 | - [x] Loon (SS, SSR, VMess, Trojan, HTTP, SOCKS5, SOCKS5-TLS, WireGuard, VLESS, Hysteria 2)
40 | - [x] Surge (Direct, SS, VMess, Trojan, HTTP, SOCKS5, SOCKS5-TLS, TUIC, Snell, Hysteria 2, SSH(Password authentication only), External Proxy Program(only for macOS), WireGuard(Surge to Surge))
41 | - [x] Surfboard (SS, VMess, Trojan, HTTP, SOCKS5, SOCKS5-TLS, WireGuard(Surfboard to Surfboard))
42 | - [x] Clash.Meta (Direct, SS, SSR, VMess, Trojan, HTTP, SOCKS5, Snell, VLESS, WireGuard, Hysteria, Hysteria 2, TUIC, SSH, mieru, AnyTLS)
43 | - [x] Stash (SS, SSR, VMess, Trojan, HTTP, SOCKS5, Snell, VLESS, WireGuard, Hysteria, TUIC, Juicity, SSH)
44 |
45 | Deprecated:
46 |
47 | - [x] Clash (SS, SSR, VMess, Trojan, HTTP, SOCKS5, Snell, VLESS, WireGuard)
48 |
49 | ### Supported Target Platforms
50 |
51 | - [x] Plain JSON
52 | - [x] Stash
53 | - [x] Clash.Meta(mihomo)
54 | - [x] Surfboard
55 | - [x] Surge
56 | - [x] SurgeMac(Use mihomo to support protocols that are not supported by Surge itself)
57 | - [x] Loon
58 | - [x] Egern
59 | - [x] Shadowrocket
60 | - [x] QX
61 | - [x] sing-box
62 | - [x] V2Ray
63 | - [x] V2Ray URI
64 |
65 | Deprecated:
66 |
67 | - [x] Clash
68 |
69 | ## 2. Subscription Formatting
70 |
71 | ### Filtering
72 |
73 | - [x] **Regex filter**
74 | - [x] **Discard regex filter**
75 | - [x] **Region filter**
76 | - [x] **Type filter**
77 | - [x] **Useless proxies filter**
78 | - [x] **Script filter**
79 |
80 | ### Proxy Operations
81 |
82 | - [x] **Set property operator**: set some proxy properties such as `udp`,`tfo`, `skip-cert-verify` etc.
83 | - [x] **Flag operator**: add flags or remove flags for proxies.
84 | - [x] **Sort operator**: sort proxies by name.
85 | - [x] **Regex sort operator**: sort proxies by keywords (fallback to normal sort).
86 | - [x] **Regex rename operator**: replace by regex in proxy names.
87 | - [x] **Regex delete operator**: delete by regex in proxy names.
88 | - [x] **Script operator**: modify proxy by script.
89 | - [x] **Resolve Domain Operator**: resolve the domain of nodes to an IP address.
90 |
91 | ### Development
92 |
93 | Install `pnpm`
94 |
95 | Go to `backend` directories, install node dependencies:
96 |
97 | ```
98 | pnpm i
99 | ```
100 |
101 | 1. In `backend`, run the backend server on http://localhost:3000
102 |
103 | babel(old school)
104 |
105 | ```
106 | pnpm start
107 | ```
108 |
109 | or
110 |
111 | esbuild(experimental)
112 |
113 | ```
114 | SUB_STORE_BACKEND_API_PORT=3000 pnpm run --parallel "/^dev:.*/"
115 | ```
116 |
117 | ## LICENSE
118 |
119 | This project is under the GPL V3 LICENSE.
120 |
121 | [](https://app.fossa.com/projects/git%2Bgithub.com%2FPeng-YM%2FSub-Store?ref=badge_large)
122 |
123 | ## Star History
124 |
125 | [](https://star-history.com/#sub-store-org/sub-store&Date)
126 |
127 | ## Acknowledgements
128 |
129 | - Special thanks to @KOP-XIAO for his awesome resource-parser. Please give a [star](https://github.com/KOP-XIAO/QuantumultX) for his great work!
130 | - Special thanks to @Orz-3 and @58xinian for their awesome icons.
131 |
132 | ## Sponsors
133 |
134 | [](https://yxvm.com)
135 |
136 | [NodeSupport](https://github.com/NodeSeekDev/NodeSupport) sponsored this project.
137 |
--------------------------------------------------------------------------------
/backend/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/preset-env"
5 | ]
6 | ],
7 | "env": {
8 | "test": {
9 | "presets": [
10 | "@babel/preset-env"
11 | ]
12 | }
13 | },
14 | "plugins": [
15 | [
16 | "babel-plugin-relative-path-import",
17 | {
18 | "paths": [
19 | {
20 | "rootPathPrefix": "@",
21 | "rootPathSuffix": "src"
22 | }
23 | ]
24 | }
25 | ]
26 | ]
27 | }
--------------------------------------------------------------------------------
/backend/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "ignorePatterns": ["*.min.js", "src/vendor/*.js"],
3 | "env": {
4 | "browser": true,
5 | "es2021": true,
6 | "node": true
7 | },
8 | "extends": "eslint:recommended",
9 | "parserOptions": {
10 | "ecmaVersion": "latest",
11 | "sourceType": "module"
12 | },
13 | "rules": {
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/backend/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "all",
4 | "tabWidth": 4,
5 | "bracketSpacing": true
6 | }
7 |
--------------------------------------------------------------------------------
/backend/banner:
--------------------------------------------------------------------------------
1 | /**
2 | * ███████╗██╗ ██╗██████╗ ███████╗████████╗ ██████╗ ██████╗ ███████╗
3 | * ██╔════╝██║ ██║██╔══██╗ ██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗██╔════╝
4 | * ███████╗██║ ██║██████╔╝█████╗███████╗ ██║ ██║ ██║██████╔╝█████╗
5 | * ╚════██║██║ ██║██╔══██╗╚════╝╚════██║ ██║ ██║ ██║██╔══██╗██╔══╝
6 | * ███████║╚██████╔╝██████╔╝ ███████║ ██║ ╚██████╔╝██║ ██║███████╗
7 | * ╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝
8 | * Advanced Subscription Manager for QX, Loon, Surge, Stash and Shadowrocket!
9 | * @updated: <%= updated %>
10 | * @version: <%= pkg.version %>
11 | * @author: Peng-YM
12 | * @github: https://github.com/sub-store-org/Sub-Store
13 | * @documentation: https://www.notion.so/Sub-Store-6259586994d34c11a4ced5c406264b46
14 | */
15 |
16 |
--------------------------------------------------------------------------------
/backend/bundle-esbuild.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | const fs = require('fs');
3 | const path = require('path');
4 | const { build } = require('esbuild');
5 |
6 | !(async () => {
7 | const version = JSON.parse(
8 | fs.readFileSync(path.join(__dirname, 'package.json'), 'utf-8'),
9 | ).version.trim();
10 |
11 | const artifacts = [
12 | { src: 'src/main.js', dest: 'sub-store.min.js' },
13 | {
14 | src: 'src/products/resource-parser.loon.js',
15 | dest: 'dist/sub-store-parser.loon.min.js',
16 | },
17 | {
18 | src: 'src/products/cron-sync-artifacts.js',
19 | dest: 'dist/cron-sync-artifacts.min.js',
20 | },
21 | { src: 'src/products/sub-store-0.js', dest: 'dist/sub-store-0.min.js' },
22 | { src: 'src/products/sub-store-1.js', dest: 'dist/sub-store-1.min.js' },
23 | ];
24 |
25 | for await (const artifact of artifacts) {
26 | await build({
27 | entryPoints: [artifact.src],
28 | bundle: true,
29 | minify: true,
30 | sourcemap: false,
31 | platform: 'browser',
32 | format: 'iife',
33 | outfile: artifact.dest,
34 | });
35 | }
36 |
37 | let content = fs.readFileSync(path.join(__dirname, 'sub-store.min.js'), {
38 | encoding: 'utf8',
39 | });
40 | content = content.replace(
41 | /eval\(('|")(require\(('|").*?('|")\))('|")\)/g,
42 | '$2',
43 | );
44 | fs.writeFileSync(
45 | path.join(__dirname, 'dist/sub-store.no-bundle.js'),
46 | content,
47 | {
48 | encoding: 'utf8',
49 | },
50 | );
51 |
52 | await build({
53 | entryPoints: ['dist/sub-store.no-bundle.js'],
54 | bundle: true,
55 | minify: true,
56 | sourcemap: false,
57 | platform: 'node',
58 | format: 'cjs',
59 | outfile: 'dist/sub-store.bundle.js',
60 | });
61 | fs.writeFileSync(
62 | path.join(__dirname, 'dist/sub-store.bundle.js'),
63 | `// SUB_STORE_BACKEND_VERSION: ${version}
64 | ${fs.readFileSync(path.join(__dirname, 'dist/sub-store.bundle.js'), {
65 | encoding: 'utf8',
66 | })}`,
67 | {
68 | encoding: 'utf8',
69 | },
70 | );
71 | })()
72 | .catch((e) => {
73 | console.log(e);
74 | })
75 | .finally(() => {
76 | console.log('done');
77 | });
78 |
--------------------------------------------------------------------------------
/backend/bundle.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | const fs = require('fs');
3 | const path = require('path');
4 | const { build } = require('esbuild');
5 |
6 | !(async () => {
7 | const version = JSON.parse(
8 | fs.readFileSync(path.join(__dirname, 'package.json'), 'utf-8'),
9 | ).version.trim();
10 |
11 | let content = fs.readFileSync(path.join(__dirname, 'sub-store.min.js'), {
12 | encoding: 'utf8',
13 | });
14 | content = content.replace(
15 | /eval\(('|")(require\(('|").*?('|")\))('|")\)/g,
16 | '$2',
17 | );
18 | fs.writeFileSync(
19 | path.join(__dirname, 'dist/sub-store.no-bundle.js'),
20 | content,
21 | {
22 | encoding: 'utf8',
23 | },
24 | );
25 |
26 | await build({
27 | entryPoints: ['dist/sub-store.no-bundle.js'],
28 | bundle: true,
29 | minify: true,
30 | sourcemap: true,
31 | platform: 'node',
32 | format: 'cjs',
33 | outfile: 'dist/sub-store.bundle.js',
34 | });
35 | fs.writeFileSync(
36 | path.join(__dirname, 'dist/sub-store.bundle.js'),
37 | `// SUB_STORE_BACKEND_VERSION: ${version}
38 | ${fs.readFileSync(path.join(__dirname, 'dist/sub-store.bundle.js'), {
39 | encoding: 'utf8',
40 | })}`,
41 | {
42 | encoding: 'utf8',
43 | },
44 | );
45 | })()
46 | .catch((e) => {
47 | console.log(e);
48 | })
49 | .finally(() => {
50 | console.log('done');
51 | });
52 |
--------------------------------------------------------------------------------
/backend/dev-esbuild.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | const { build } = require('esbuild');
3 |
4 | !(async () => {
5 | const artifacts = [{ src: 'src/main.js', dest: 'sub-store.min.js' }];
6 |
7 | for await (const artifact of artifacts) {
8 | await build({
9 | entryPoints: [artifact.src],
10 | bundle: true,
11 | minify: false,
12 | sourcemap: false,
13 | platform: 'node',
14 | format: 'cjs',
15 | outfile: artifact.dest,
16 | });
17 | }
18 | })()
19 | .catch((e) => {
20 | console.log(e);
21 | })
22 | .finally(() => {
23 | console.log('done');
24 | });
25 |
--------------------------------------------------------------------------------
/backend/dist/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sub-store-org/Sub-Store/4aafdaaddbc7ba24651501eaaa25da69219084d9/backend/dist/.gitkeep
--------------------------------------------------------------------------------
/backend/gulpfile.babel.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import browserify from 'browserify';
3 | import gulp from 'gulp';
4 | import prettier from 'gulp-prettier';
5 | import header from 'gulp-header';
6 | import eslint from 'gulp-eslint-new';
7 | import newFile from 'gulp-file';
8 | import path from 'path';
9 | import tap from 'gulp-tap';
10 |
11 | import pkg from './package.json';
12 |
13 | export function peggy() {
14 | return gulp.src('src/**/*.peg').pipe(
15 | tap(function (file) {
16 | const filename = path.basename(file.path).split('.')[0] + '.js';
17 | const raw = fs.readFileSync(file.path, 'utf8');
18 | const contents = `import * as peggy from 'peggy';
19 | const grammars = String.raw\`\n${raw}\n\`;
20 | let parser;
21 | export default function getParser() {
22 | if (!parser) {
23 | parser = peggy.generate(grammars);
24 | }
25 | return parser;
26 | }\n`;
27 | return newFile(filename, contents).pipe(
28 | gulp.dest(path.dirname(file.path)),
29 | );
30 | }),
31 | );
32 | }
33 |
34 | export function lint() {
35 | return gulp
36 | .src('src/**/*.js')
37 | .pipe(eslint({ fix: true }))
38 | .pipe(eslint.fix())
39 | .pipe(eslint.format())
40 | .pipe(eslint.failAfterError());
41 | }
42 |
43 | export function styles() {
44 | return gulp
45 | .src('src/**/*.js')
46 | .pipe(
47 | prettier({
48 | singleQuote: true,
49 | trailingComma: 'all',
50 | tabWidth: 4,
51 | bracketSpacing: true,
52 | }),
53 | )
54 | .pipe(gulp.dest((file) => file.base));
55 | }
56 |
57 | function scripts(src, dest) {
58 | return () => {
59 | return browserify(src)
60 | .transform('babelify', {
61 | presets: [['@babel/preset-env']],
62 | plugins: [
63 | [
64 | 'babel-plugin-relative-path-import',
65 | {
66 | paths: [
67 | {
68 | rootPathPrefix: '@',
69 | rootPathSuffix: 'src',
70 | },
71 | ],
72 | },
73 | ],
74 | ],
75 | })
76 | .plugin('tinyify')
77 | .bundle()
78 | .pipe(fs.createWriteStream(dest));
79 | };
80 | }
81 |
82 | function banner(dest) {
83 | return () =>
84 | gulp
85 | .src(dest)
86 | .pipe(
87 | header(fs.readFileSync('./banner', 'utf-8'), {
88 | pkg,
89 | updated: new Date().toLocaleString('zh-CN'),
90 | }),
91 | )
92 | .pipe(gulp.dest((file) => file.base));
93 | }
94 |
95 | const artifacts = [
96 | { src: 'src/main.js', dest: 'sub-store.min.js' },
97 | {
98 | src: 'src/products/resource-parser.loon.js',
99 | dest: 'dist/sub-store-parser.loon.min.js',
100 | },
101 | {
102 | src: 'src/products/cron-sync-artifacts.js',
103 | dest: 'dist/cron-sync-artifacts.min.js',
104 | },
105 | { src: 'src/products/sub-store-0.js', dest: 'dist/sub-store-0.min.js' },
106 | { src: 'src/products/sub-store-1.js', dest: 'dist/sub-store-1.min.js' },
107 | ];
108 |
109 | export const build = gulp.series(
110 | gulp.parallel(
111 | artifacts.map((artifact) => scripts(artifact.src, artifact.dest)),
112 | ),
113 | gulp.parallel(artifacts.map((artifact) => banner(artifact.dest))),
114 | );
115 |
116 | const all = gulp.series(peggy, lint, styles, build);
117 |
118 | export default all;
119 |
--------------------------------------------------------------------------------
/backend/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "@/*": ["src/*"]
6 | }
7 | }
8 | }
--------------------------------------------------------------------------------
/backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sub-store",
3 | "version": "2.19.57",
4 | "description": "Advanced Subscription Manager for QX, Loon, Surge, Stash and Shadowrocket.",
5 | "main": "src/main.js",
6 | "scripts": {
7 | "preinstall": "npx only-allow pnpm",
8 | "test": "gulp peggy && npx cross-env BABEL_ENV=test mocha src/test/**/*.spec.js --require @babel/register --recursive",
9 | "serve": "node sub-store.min.js",
10 | "start": "nodemon -w src -w package.json --exec babel-node src/main.js",
11 | "dev:esbuild": "nodemon -w src -w package.json dev-esbuild.js",
12 | "dev:run": "nodemon -w sub-store.min.js sub-store.min.js",
13 | "build": "gulp",
14 | "bundle": "node bundle.js",
15 | "bundle:esbuild": "node bundle-esbuild.js",
16 | "changelog": "conventional-changelog -p cli -i CHANGELOG.md -s"
17 | },
18 | "author": "Peng-YM",
19 | "license": "GPL-3.0",
20 | "pnpm": {
21 | "patchedDependencies": {
22 | "http-proxy@1.18.1": "patches/http-proxy@1.18.1.patch"
23 | }
24 | },
25 | "dependencies": {
26 | "@maxmind/geoip2-node": "^5.0.0",
27 | "automerge": "1.0.1-preview.7",
28 | "body-parser": "^1.19.0",
29 | "buffer": "^6.0.3",
30 | "dotenv": "^16.4.7",
31 | "connect-history-api-fallback": "^2.0.0",
32 | "cron": "^3.1.6",
33 | "dns-packet": "^5.6.1",
34 | "express": "^4.17.1",
35 | "mime-types": "^2.1.35",
36 | "http-proxy-middleware": "^3.0.3",
37 | "ip-address": "^9.0.5",
38 | "js-base64": "^3.7.2",
39 | "jsrsasign": "^11.1.0",
40 | "lodash": "^4.17.21",
41 | "ms": "^2.1.3",
42 | "nanoid": "^3.3.3",
43 | "semver": "^7.6.3",
44 | "static-js-yaml": "^1.0.0",
45 | "undici": "^7.4.0"
46 | },
47 | "devDependencies": {
48 | "@babel/core": "^7.18.0",
49 | "@babel/node": "^7.17.10",
50 | "@babel/preset-env": "^7.18.0",
51 | "@babel/register": "^7.17.7",
52 | "@types/gulp": "^4.0.9",
53 | "axios": "^0.21.2",
54 | "babel-plugin-relative-path-import": "^2.0.1",
55 | "babelify": "^10.0.0",
56 | "browser-pack-flat": "^3.4.2",
57 | "browserify": "^17.0.0",
58 | "chai": "^4.3.6",
59 | "esbuild": "^0.19.8",
60 | "eslint": "^8.16.0",
61 | "gulp": "^4.0.2",
62 | "gulp-babel": "^8.0.0",
63 | "gulp-eslint-new": "^1.4.4",
64 | "gulp-file": "^0.4.0",
65 | "gulp-header": "^2.0.9",
66 | "gulp-prettier": "^4.0.0",
67 | "gulp-tap": "^2.0.0",
68 | "mocha": "^10.0.0",
69 | "nodemon": "^2.0.16",
70 | "peggy": "^2.0.1",
71 | "prettier": "2.6.2",
72 | "prettier-plugin-sort-imports": "^1.6.1",
73 | "tinyify": "^3.0.0"
74 | }
75 | }
--------------------------------------------------------------------------------
/backend/patches/http-proxy@1.18.1.patch:
--------------------------------------------------------------------------------
1 | diff --git a/lib/http-proxy/common.js b/lib/http-proxy/common.js
2 | index 6513e81d80d5250ea249ea833f819ece67897c7e..486d4c896d65a3bb7cf63307af68facb3ddb886b 100644
3 | --- a/lib/http-proxy/common.js
4 | +++ b/lib/http-proxy/common.js
5 | @@ -1,6 +1,5 @@
6 | var common = exports,
7 | url = require('url'),
8 | - extend = require('util')._extend,
9 | required = require('requires-port');
10 |
11 | var upgradeHeader = /(^|,)\s*upgrade\s*($|,)/i,
12 | @@ -40,10 +39,10 @@ common.setupOutgoing = function(outgoing, options, req, forward) {
13 | );
14 |
15 | outgoing.method = options.method || req.method;
16 | - outgoing.headers = extend({}, req.headers);
17 | + outgoing.headers = Object.assign({}, req.headers);
18 |
19 | if (options.headers){
20 | - extend(outgoing.headers, options.headers);
21 | + Object.assign(outgoing.headers, options.headers);
22 | }
23 |
24 | if (options.auth) {
25 | diff --git a/lib/http-proxy/index.js b/lib/http-proxy/index.js
26 | index 977a4b3622b9eaac27689f06347ea4c5173a96cd..88b2d0fcfa03c3aafa47c7e6d38e64412c45a7cc 100644
27 | --- a/lib/http-proxy/index.js
28 | +++ b/lib/http-proxy/index.js
29 | @@ -1,5 +1,4 @@
30 | var httpProxy = module.exports,
31 | - extend = require('util')._extend,
32 | parse_url = require('url').parse,
33 | EE3 = require('eventemitter3'),
34 | http = require('http'),
35 | @@ -47,9 +46,9 @@ function createRightProxy(type) {
36 | args[cntr] !== res
37 | ) {
38 | //Copy global options
39 | - requestOptions = extend({}, options);
40 | + requestOptions = Object.assign({}, options);
41 | //Overwrite with request options
42 | - extend(requestOptions, args[cntr]);
43 | + Object.assign(requestOptions, args[cntr]);
44 |
45 | cntr--;
46 | }
47 |
--------------------------------------------------------------------------------
/backend/src/constants.js:
--------------------------------------------------------------------------------
1 | export const SCHEMA_VERSION_KEY = 'schemaVersion';
2 | export const SETTINGS_KEY = 'settings';
3 | export const SUBS_KEY = 'subs';
4 | export const COLLECTIONS_KEY = 'collections';
5 | export const FILES_KEY = 'files';
6 | export const MODULES_KEY = 'modules';
7 | export const ARTIFACTS_KEY = 'artifacts';
8 | export const RULES_KEY = 'rules';
9 | export const TOKENS_KEY = 'tokens';
10 | export const GIST_BACKUP_KEY = 'Auto Generated Sub-Store Backup';
11 | export const GIST_BACKUP_FILE_NAME = 'Sub-Store';
12 | export const ARTIFACT_REPOSITORY_KEY = 'Sub-Store Artifacts Repository';
13 | export const RESOURCE_CACHE_KEY = '#sub-store-cached-resource';
14 | export const HEADERS_RESOURCE_CACHE_KEY = '#sub-store-cached-headers-resource';
15 | export const CHR_EXPIRATION_TIME_KEY = '#sub-store-chr-expiration-time'; // Custom expiration time key; (Loon|Surge) Default write 1 min
16 | export const CACHE_EXPIRATION_TIME_MS = 60 * 60 * 1000; // 1 hour
17 | export const SCRIPT_RESOURCE_CACHE_KEY = '#sub-store-cached-script-resource'; // cached-script-resource CSR
18 | export const CSR_EXPIRATION_TIME_KEY = '#sub-store-csr-expiration-time'; // Custom expiration time key; (Loon|Surge) Default write 48 hour
19 |
--------------------------------------------------------------------------------
/backend/src/core/app.js:
--------------------------------------------------------------------------------
1 | import { OpenAPI } from '@/vendor/open-api';
2 |
3 | const $ = new OpenAPI('sub-store');
4 | export default $;
5 |
--------------------------------------------------------------------------------
/backend/src/core/proxy-utils/parsers/peggy/qx.js:
--------------------------------------------------------------------------------
1 | import * as peggy from 'peggy';
2 | const grammars = String.raw`
3 | // global initializer
4 | {{
5 | function $set(obj, path, value) {
6 | if (Object(obj) !== obj) return obj;
7 | if (!Array.isArray(path)) path = path.toString().match(/[^.[\]]+/g) || [];
8 | path
9 | .slice(0, -1)
10 | .reduce((a, c, i) => (Object(a[c]) === a[c] ? a[c] : (a[c] = Math.abs(path[i + 1]) >> 0 === +path[i + 1] ? [] : {})), obj)[
11 | path[path.length - 1]
12 | ] = value;
13 | return obj;
14 | }
15 | }}
16 |
17 | // per-parse initializer
18 | {
19 | const proxy = {};
20 | const obfs = {};
21 | const $ = {};
22 |
23 | function handleObfs() {
24 | if (obfs.type === "ws" || obfs.type === "wss") {
25 | proxy.network = "ws";
26 | if (obfs.type === 'wss') {
27 | proxy.tls = true;
28 | }
29 | $set(proxy, "ws-opts.path", obfs.path);
30 | $set(proxy, "ws-opts.headers.Host", obfs.host);
31 | } else if (obfs.type === "over-tls") {
32 | proxy.tls = true;
33 | } else if (obfs.type === "http") {
34 | proxy.network = "http";
35 | $set(proxy, "http-opts.path", obfs.path);
36 | $set(proxy, "http-opts.headers.Host", obfs.host);
37 | }
38 | }
39 | }
40 |
41 | start = (trojan/shadowsocks/vmess/vless/http/socks5) {
42 | return proxy
43 | }
44 |
45 | trojan = "trojan" equals address
46 | (password/over_tls/tls_host/tls_pubkey_sha256/tls_alpn/tls_no_session_ticket/tls_no_session_reuse/tls_fingerprint/tls_verification/obfs/obfs_host/obfs_uri/tag/udp_relay/udp_over_tcp/fast_open/server_check_url/others)* {
47 | proxy.type = "trojan";
48 | handleObfs();
49 | }
50 |
51 | shadowsocks = "shadowsocks" equals address
52 | (password/method/obfs_ssr/obfs_ss/obfs_host/obfs_uri/ssr_protocol/ssr_protocol_param/tls_pubkey_sha256/tls_alpn/tls_no_session_ticket/tls_no_session_reuse/tls_fingerprint/tls_verification/udp_relay/udp_over_tcp_new/fast_open/tag/server_check_url/others)* {
53 | if (proxy.protocol || proxy.type === "ssr") {
54 | proxy.type = "ssr";
55 | if (!proxy.protocol) {
56 | proxy.protocol = "origin";
57 | }
58 | // handle ssr obfs
59 | if (obfs.host) proxy["obfs-param"] = obfs.host;
60 | if (obfs.type) proxy.obfs = obfs.type;
61 | } else {
62 | proxy.type = "ss";
63 | // handle ss obfs
64 | if (obfs.type == "http" || obfs.type === "tls") {
65 | proxy.plugin = "obfs";
66 | $set(proxy, "plugin-opts", {
67 | mode: obfs.type
68 | });
69 | } else if (obfs.type === "ws" || obfs.type === "wss") {
70 | proxy.plugin = "v2ray-plugin";
71 | $set(proxy, "plugin-opts.mode", "websocket");
72 | if (obfs.type === "wss") {
73 | $set(proxy, "plugin-opts.tls", true);
74 | }
75 | } else if (obfs.type === 'over-tls') {
76 | throw new Error('ss over-tls is not supported');
77 | }
78 | if (obfs.type) {
79 | $set(proxy, "plugin-opts.host", obfs.host);
80 | $set(proxy, "plugin-opts.path", obfs.path);
81 | }
82 | }
83 | }
84 |
85 | vmess = "vmess" equals address
86 | (uuid/method/over_tls/tls_host/tls_pubkey_sha256/tls_alpn/tls_no_session_ticket/tls_no_session_reuse/tls_fingerprint/tls_verification/tag/obfs/obfs_host/obfs_uri/udp_relay/udp_over_tcp/fast_open/aead/server_check_url/others)* {
87 | proxy.type = "vmess";
88 | proxy.cipher = proxy.cipher || "none";
89 | if (proxy.aead === false) {
90 | proxy.alterId = 1;
91 | } else {
92 | proxy.alterId = 0;
93 | }
94 | handleObfs();
95 | }
96 |
97 | vless = "vless" equals address
98 | (uuid/method/over_tls/tls_host/tls_pubkey_sha256/tls_alpn/tls_no_session_ticket/tls_no_session_reuse/tls_fingerprint/tls_verification/tag/obfs/obfs_host/obfs_uri/udp_relay/udp_over_tcp/fast_open/aead/server_check_url/others)* {
99 | proxy.type = "vless";
100 | proxy.cipher = proxy.cipher || "none";
101 | handleObfs();
102 | }
103 |
104 | http = "http" equals address
105 | (username/password/over_tls/tls_host/tls_pubkey_sha256/tls_alpn/tls_no_session_ticket/tls_no_session_reuse/tls_fingerprint/tls_verification/tag/fast_open/udp_relay/udp_over_tcp/server_check_url/others)*{
106 | proxy.type = "http";
107 | }
108 |
109 | socks5 = "socks5" equals address
110 | (username/password/password/over_tls/tls_host/tls_pubkey_sha256/tls_alpn/tls_no_session_ticket/tls_no_session_reuse/tls_fingerprint/tls_verification/tag/fast_open/udp_relay/udp_over_tcp/server_check_url/others)* {
111 | proxy.type = "socks5";
112 | }
113 |
114 | address = server:server ":" port:port {
115 | proxy.server = server;
116 | proxy.port = port;
117 | }
118 | server = ip/domain
119 |
120 | domain = match:[0-9a-zA-z-_.]+ {
121 | const domain = match.join("");
122 | if (/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/.test(domain)) {
123 | return domain;
124 | }
125 | }
126 |
127 | ip = & {
128 | const start = peg$currPos;
129 | let end;
130 | let j = start;
131 | while (j < input.length) {
132 | if (input[j] === ",") break;
133 | if (input[j] === ":") end = j;
134 | j++;
135 | }
136 | peg$currPos = end || j;
137 | $.ip = input.substring(start, end).trim();
138 | return true;
139 | } { return $.ip; }
140 |
141 | port = digits:[0-9]+ {
142 | const port = parseInt(digits.join(""), 10);
143 | if (port >= 0 && port <= 65535) {
144 | return port;
145 | }
146 | }
147 |
148 | username = comma "username" equals username:[^,]+ { proxy.username = username.join("").trim(); }
149 | password = comma "password" equals password:[^,]+ { proxy.password = password.join("").trim(); }
150 | uuid = comma "password" equals uuid:[^,]+ { proxy.uuid = uuid.join("").trim(); }
151 |
152 | method = comma "method" equals cipher:cipher {
153 | proxy.cipher = cipher;
154 | };
155 | cipher = ("aes-128-cfb"/"aes-128-ctr"/"aes-128-gcm"/"aes-192-cfb"/"aes-192-ctr"/"aes-192-gcm"/"aes-256-cfb"/"aes-256-ctr"/"aes-256-gcm"/"bf-cfb"/"cast5-cfb"/"chacha20-ietf-poly1305"/"chacha20-ietf"/"chacha20-poly1305"/"chacha20"/"des-cfb"/"none"/"rc2-cfb"/"rc4-md5-6"/"rc4-md5"/"salsa20"/"xchacha20-ietf-poly1305"/"2022-blake3-aes-128-gcm"/"2022-blake3-aes-256-gcm");
156 | aead = comma "aead" equals flag:bool { proxy.aead = flag; }
157 |
158 | udp_relay = comma "udp-relay" equals flag:bool { proxy.udp = flag; }
159 | udp_over_tcp = comma "udp-over-tcp" equals flag:bool { throw new Error("UDP over TCP is not supported"); }
160 | udp_over_tcp_new = comma "udp-over-tcp" equals param:$[^=,]+ { if (param === "sp.v1") { proxy["udp-over-tcp"] = true; proxy["udp-over-tcp-version"] = 1; } else if (param === "sp.v2") { proxy["udp-over-tcp"] = true; proxy["udp-over-tcp-version"] = 2; } else if (param === "true") { proxy["_ssr_python_uot"] = true; } else { throw new Error("Invalid value for udp-over-tcp"); } }
161 |
162 | fast_open = comma "fast-open" equals flag:bool { proxy.tfo = flag; }
163 |
164 | over_tls = comma "over-tls" equals flag:bool { proxy.tls = flag; }
165 | tls_host = comma "tls-host" equals sni:domain { proxy.sni = sni; }
166 | tls_verification = comma "tls-verification" equals flag:bool {
167 | proxy["skip-cert-verify"] = !flag;
168 | }
169 | tls_fingerprint = comma "tls-cert-sha256" equals tls_fingerprint:$[^,]+ { proxy["tls-fingerprint"] = tls_fingerprint.trim(); }
170 | tls_pubkey_sha256 = comma "tls-pubkey-sha256" equals param:$[^=,]+ { proxy["tls-pubkey-sha256"] = param; }
171 | tls_alpn = comma "tls-alpn" equals param:$[^=,]+ { proxy["tls-alpn"] = param; }
172 | tls_no_session_ticket = comma "tls-no-session-ticket" equals flag:bool {
173 | proxy["tls-no-session-ticket"] = flag;
174 | }
175 | tls_no_session_reuse = comma "tls-no-session-reuse" equals flag:bool {
176 | proxy["tls-no-session-reuse"] = flag;
177 | }
178 |
179 | obfs_ss = comma "obfs" equals type:("http"/"tls"/"wss"/"ws"/"over-tls") { obfs.type = type; return type; }
180 | obfs_ssr = comma "obfs" equals type:("plain"/"http_simple"/"http_post"/"random_head"/"tls1.2_ticket_auth"/"tls1.2_ticket_fastauth") { proxy.type = "ssr"; obfs.type = type; return type; }
181 | obfs = comma "obfs" equals type:("wss"/"ws"/"over-tls"/"http") { obfs.type = type; return type; };
182 |
183 | obfs_host = comma "obfs-host" equals host:domain { obfs.host = host; }
184 | obfs_uri = comma "obfs-uri" equals uri:uri { obfs.path = uri; }
185 |
186 | ssr_protocol = comma "ssr-protocol" equals protocol:("origin"/"auth_sha1_v4"/"auth_aes128_md5"/"auth_aes128_sha1"/"auth_chain_a"/"auth_chain_b") { proxy.protocol = protocol; return protocol; }
187 | ssr_protocol_param = comma "ssr-protocol-param" equals param:$[^=,]+ { proxy["protocol-param"] = param; }
188 |
189 | server_check_url = comma "server_check_url" equals param:$[^=,]+ { proxy["test-url"] = param; }
190 |
191 | uri = $[^,]+
192 |
193 | tag = comma "tag" equals tag:[^=,]+ { proxy.name = tag.join(""); }
194 | others = comma [^=,]+ equals [^=,]+
195 | comma = _ "," _
196 | equals = _ "=" _
197 | _ = [ \r\t]*
198 | bool = b:("true"/"false") { return b === "true" }
199 | `;
200 | let parser;
201 | export default function getParser() {
202 | if (!parser) {
203 | parser = peggy.generate(grammars);
204 | }
205 | return parser;
206 | }
207 |
--------------------------------------------------------------------------------
/backend/src/core/proxy-utils/parsers/peggy/qx.peg:
--------------------------------------------------------------------------------
1 | // global initializer
2 | {{
3 | function $set(obj, path, value) {
4 | if (Object(obj) !== obj) return obj;
5 | if (!Array.isArray(path)) path = path.toString().match(/[^.[\]]+/g) || [];
6 | path
7 | .slice(0, -1)
8 | .reduce((a, c, i) => (Object(a[c]) === a[c] ? a[c] : (a[c] = Math.abs(path[i + 1]) >> 0 === +path[i + 1] ? [] : {})), obj)[
9 | path[path.length - 1]
10 | ] = value;
11 | return obj;
12 | }
13 | }}
14 |
15 | // per-parse initializer
16 | {
17 | const proxy = {};
18 | const obfs = {};
19 | const $ = {};
20 |
21 | function handleObfs() {
22 | if (obfs.type === "ws" || obfs.type === "wss") {
23 | proxy.network = "ws";
24 | if (obfs.type === 'wss') {
25 | proxy.tls = true;
26 | }
27 | $set(proxy, "ws-opts.path", obfs.path);
28 | $set(proxy, "ws-opts.headers.Host", obfs.host);
29 | } else if (obfs.type === "over-tls") {
30 | proxy.tls = true;
31 | } else if (obfs.type === "http") {
32 | proxy.network = "http";
33 | $set(proxy, "http-opts.path", obfs.path);
34 | $set(proxy, "http-opts.headers.Host", obfs.host);
35 | }
36 | }
37 | }
38 |
39 | start = (trojan/shadowsocks/vmess/vless/http/socks5) {
40 | return proxy
41 | }
42 |
43 | trojan = "trojan" equals address
44 | (password/over_tls/tls_host/tls_pubkey_sha256/tls_alpn/tls_no_session_ticket/tls_no_session_reuse/tls_fingerprint/tls_verification/obfs/obfs_host/obfs_uri/tag/udp_relay/udp_over_tcp/fast_open/server_check_url/others)* {
45 | proxy.type = "trojan";
46 | handleObfs();
47 | }
48 |
49 | shadowsocks = "shadowsocks" equals address
50 | (password/method/obfs_ssr/obfs_ss/obfs_host/obfs_uri/ssr_protocol/ssr_protocol_param/tls_pubkey_sha256/tls_alpn/tls_no_session_ticket/tls_no_session_reuse/tls_fingerprint/tls_verification/udp_relay/udp_over_tcp_new/fast_open/tag/server_check_url/others)* {
51 | if (proxy.protocol || proxy.type === "ssr") {
52 | proxy.type = "ssr";
53 | if (!proxy.protocol) {
54 | proxy.protocol = "origin";
55 | }
56 | // handle ssr obfs
57 | if (obfs.host) proxy["obfs-param"] = obfs.host;
58 | if (obfs.type) proxy.obfs = obfs.type;
59 | } else {
60 | proxy.type = "ss";
61 | // handle ss obfs
62 | if (obfs.type == "http" || obfs.type === "tls") {
63 | proxy.plugin = "obfs";
64 | $set(proxy, "plugin-opts", {
65 | mode: obfs.type
66 | });
67 | } else if (obfs.type === "ws" || obfs.type === "wss") {
68 | proxy.plugin = "v2ray-plugin";
69 | $set(proxy, "plugin-opts.mode", "websocket");
70 | if (obfs.type === "wss") {
71 | $set(proxy, "plugin-opts.tls", true);
72 | }
73 | } else if (obfs.type === 'over-tls') {
74 | throw new Error('ss over-tls is not supported');
75 | }
76 | if (obfs.type) {
77 | $set(proxy, "plugin-opts.host", obfs.host);
78 | $set(proxy, "plugin-opts.path", obfs.path);
79 | }
80 | }
81 | }
82 |
83 | vmess = "vmess" equals address
84 | (uuid/method/over_tls/tls_host/tls_pubkey_sha256/tls_alpn/tls_no_session_ticket/tls_no_session_reuse/tls_fingerprint/tls_verification/tag/obfs/obfs_host/obfs_uri/udp_relay/udp_over_tcp/fast_open/aead/server_check_url/others)* {
85 | proxy.type = "vmess";
86 | proxy.cipher = proxy.cipher || "none";
87 | if (proxy.aead === false) {
88 | proxy.alterId = 1;
89 | } else {
90 | proxy.alterId = 0;
91 | }
92 | handleObfs();
93 | }
94 |
95 | vless = "vless" equals address
96 | (uuid/method/over_tls/tls_host/tls_pubkey_sha256/tls_alpn/tls_no_session_ticket/tls_no_session_reuse/tls_fingerprint/tls_verification/tag/obfs/obfs_host/obfs_uri/udp_relay/udp_over_tcp/fast_open/aead/server_check_url/others)* {
97 | proxy.type = "vless";
98 | proxy.cipher = proxy.cipher || "none";
99 | handleObfs();
100 | }
101 |
102 | http = "http" equals address
103 | (username/password/over_tls/tls_host/tls_pubkey_sha256/tls_alpn/tls_no_session_ticket/tls_no_session_reuse/tls_fingerprint/tls_verification/tag/fast_open/udp_relay/udp_over_tcp/server_check_url/others)*{
104 | proxy.type = "http";
105 | }
106 |
107 | socks5 = "socks5" equals address
108 | (username/password/password/over_tls/tls_host/tls_pubkey_sha256/tls_alpn/tls_no_session_ticket/tls_no_session_reuse/tls_fingerprint/tls_verification/tag/fast_open/udp_relay/udp_over_tcp/server_check_url/others)* {
109 | proxy.type = "socks5";
110 | }
111 |
112 | address = server:server ":" port:port {
113 | proxy.server = server;
114 | proxy.port = port;
115 | }
116 | server = ip/domain
117 |
118 | domain = match:[0-9a-zA-z-_.]+ {
119 | const domain = match.join("");
120 | if (/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/.test(domain)) {
121 | return domain;
122 | }
123 | }
124 |
125 | ip = & {
126 | const start = peg$currPos;
127 | let end;
128 | let j = start;
129 | while (j < input.length) {
130 | if (input[j] === ",") break;
131 | if (input[j] === ":") end = j;
132 | j++;
133 | }
134 | peg$currPos = end || j;
135 | $.ip = input.substring(start, end).trim();
136 | return true;
137 | } { return $.ip; }
138 |
139 | port = digits:[0-9]+ {
140 | const port = parseInt(digits.join(""), 10);
141 | if (port >= 0 && port <= 65535) {
142 | return port;
143 | }
144 | }
145 |
146 | username = comma "username" equals username:[^,]+ { proxy.username = username.join("").trim(); }
147 | password = comma "password" equals password:[^,]+ { proxy.password = password.join("").trim(); }
148 | uuid = comma "password" equals uuid:[^,]+ { proxy.uuid = uuid.join("").trim(); }
149 |
150 | method = comma "method" equals cipher:cipher {
151 | proxy.cipher = cipher;
152 | };
153 | cipher = ("aes-128-cfb"/"aes-128-ctr"/"aes-128-gcm"/"aes-192-cfb"/"aes-192-ctr"/"aes-192-gcm"/"aes-256-cfb"/"aes-256-ctr"/"aes-256-gcm"/"bf-cfb"/"cast5-cfb"/"chacha20-ietf-poly1305"/"chacha20-ietf"/"chacha20-poly1305"/"chacha20"/"des-cfb"/"none"/"rc2-cfb"/"rc4-md5-6"/"rc4-md5"/"salsa20"/"xchacha20-ietf-poly1305"/"2022-blake3-aes-128-gcm"/"2022-blake3-aes-256-gcm");
154 | aead = comma "aead" equals flag:bool { proxy.aead = flag; }
155 |
156 | udp_relay = comma "udp-relay" equals flag:bool { proxy.udp = flag; }
157 | udp_over_tcp = comma "udp-over-tcp" equals flag:bool { throw new Error("UDP over TCP is not supported"); }
158 | udp_over_tcp_new = comma "udp-over-tcp" equals param:$[^=,]+ { if (param === "sp.v1") { proxy["udp-over-tcp"] = true; proxy["udp-over-tcp-version"] = 1; } else if (param === "sp.v2") { proxy["udp-over-tcp"] = true; proxy["udp-over-tcp-version"] = 2; } else if (param === "true") { proxy["_ssr_python_uot"] = true; } else { throw new Error("Invalid value for udp-over-tcp"); } }
159 |
160 | fast_open = comma "fast-open" equals flag:bool { proxy.tfo = flag; }
161 |
162 | over_tls = comma "over-tls" equals flag:bool { proxy.tls = flag; }
163 | tls_host = comma "tls-host" equals sni:domain { proxy.sni = sni; }
164 | tls_verification = comma "tls-verification" equals flag:bool {
165 | proxy["skip-cert-verify"] = !flag;
166 | }
167 | tls_fingerprint = comma "tls-cert-sha256" equals tls_fingerprint:$[^,]+ { proxy["tls-fingerprint"] = tls_fingerprint.trim(); }
168 | tls_pubkey_sha256 = comma "tls-pubkey-sha256" equals param:$[^=,]+ { proxy["tls-pubkey-sha256"] = param; }
169 | tls_alpn = comma "tls-alpn" equals param:$[^=,]+ { proxy["tls-alpn"] = param; }
170 | tls_no_session_ticket = comma "tls-no-session-ticket" equals flag:bool {
171 | proxy["tls-no-session-ticket"] = flag;
172 | }
173 | tls_no_session_reuse = comma "tls-no-session-reuse" equals flag:bool {
174 | proxy["tls-no-session-reuse"] = flag;
175 | }
176 |
177 | obfs_ss = comma "obfs" equals type:("http"/"tls"/"wss"/"ws"/"over-tls") { obfs.type = type; return type; }
178 | obfs_ssr = comma "obfs" equals type:("plain"/"http_simple"/"http_post"/"random_head"/"tls1.2_ticket_auth"/"tls1.2_ticket_fastauth") { proxy.type = "ssr"; obfs.type = type; return type; }
179 | obfs = comma "obfs" equals type:("wss"/"ws"/"over-tls"/"http") { obfs.type = type; return type; };
180 |
181 | obfs_host = comma "obfs-host" equals host:domain { obfs.host = host; }
182 | obfs_uri = comma "obfs-uri" equals uri:uri { obfs.path = uri; }
183 |
184 | ssr_protocol = comma "ssr-protocol" equals protocol:("origin"/"auth_sha1_v4"/"auth_aes128_md5"/"auth_aes128_sha1"/"auth_chain_a"/"auth_chain_b") { proxy.protocol = protocol; return protocol; }
185 | ssr_protocol_param = comma "ssr-protocol-param" equals param:$[^=,]+ { proxy["protocol-param"] = param; }
186 |
187 | server_check_url = comma "server_check_url" equals param:$[^=,]+ { proxy["test-url"] = param; }
188 |
189 | uri = $[^,]+
190 |
191 | tag = comma "tag" equals tag:[^=,]+ { proxy.name = tag.join(""); }
192 | others = comma [^=,]+ equals [^=,]+
193 | comma = _ "," _
194 | equals = _ "=" _
195 | _ = [ \r\t]*
196 | bool = b:("true"/"false") { return b === "true" }
--------------------------------------------------------------------------------
/backend/src/core/proxy-utils/parsers/peggy/trojan-uri.js:
--------------------------------------------------------------------------------
1 | import * as peggy from 'peggy';
2 | const grammars = String.raw`
3 | // global initializer
4 | {{
5 | function $set(obj, path, value) {
6 | if (Object(obj) !== obj) return obj;
7 | if (!Array.isArray(path)) path = path.toString().match(/[^.[\]]+/g) || [];
8 | path
9 | .slice(0, -1)
10 | .reduce((a, c, i) => (Object(a[c]) === a[c] ? a[c] : (a[c] = Math.abs(path[i + 1]) >> 0 === +path[i + 1] ? [] : {})), obj)[
11 | path[path.length - 1]
12 | ] = value;
13 | return obj;
14 | }
15 |
16 | function toBool(str) {
17 | if (typeof str === 'undefined' || str === null) return undefined;
18 | return /(TRUE)|1/i.test(str);
19 | }
20 | }}
21 |
22 | {
23 | const proxy = {};
24 | const obfs = {};
25 | const $ = {};
26 | const params = {};
27 | }
28 |
29 | start = (trojan) {
30 | return proxy
31 | }
32 |
33 | trojan = "trojan://" password:password "@" server:server ":" port:port "/"? params? name:name?{
34 | proxy.type = "trojan";
35 | proxy.password = password;
36 | proxy.server = server;
37 | proxy.port = port;
38 | proxy.name = name;
39 |
40 | // name may be empty
41 | if (!proxy.name) {
42 | proxy.name = server + ":" + port;
43 | }
44 | };
45 |
46 | password = match:$[^@]+ {
47 | return decodeURIComponent(match);
48 | };
49 |
50 | server = ip/domain;
51 |
52 | domain = match:[0-9a-zA-z-_.]+ {
53 | const domain = match.join("");
54 | if (/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/.test(domain)) {
55 | return domain;
56 | }
57 | }
58 |
59 | ip = & {
60 | const start = peg$currPos;
61 | let end;
62 | let j = start;
63 | while (j < input.length) {
64 | if (input[j] === ",") break;
65 | if (input[j] === ":") end = j;
66 | j++;
67 | }
68 | peg$currPos = end || j;
69 | $.ip = input.substring(start, end).trim();
70 | return true;
71 | } { return $.ip; }
72 |
73 | port = digits:[0-9]+ {
74 | const port = parseInt(digits.join(""), 10);
75 | if (port >= 0 && port <= 65535) {
76 | return port;
77 | } else {
78 | throw new Error("Invalid port: " + port);
79 | }
80 | }
81 |
82 | params = "?" head:param tail:("&"@param)* {
83 | for (const [key, value] of Object.entries(params)) {
84 | params[key] = decodeURIComponent(value);
85 | }
86 | proxy["skip-cert-verify"] = toBool(params["allowInsecure"]);
87 | proxy.sni = params["sni"] || params["peer"];
88 | proxy['client-fingerprint'] = params.fp;
89 | proxy.alpn = params.alpn ? decodeURIComponent(params.alpn).split(',') : undefined;
90 |
91 | if (toBool(params["ws"])) {
92 | proxy.network = "ws";
93 | $set(proxy, "ws-opts.path", params["wspath"]);
94 | }
95 |
96 | if (params["type"]) {
97 | let httpupgrade
98 | proxy.network = params["type"]
99 | if(proxy.network === 'httpupgrade') {
100 | proxy.network = 'ws'
101 | httpupgrade = true
102 | }
103 | if (['grpc'].includes(proxy.network)) {
104 | proxy[proxy.network + '-opts'] = {
105 | 'grpc-service-name': params["serviceName"],
106 | '_grpc-type': params["mode"],
107 | '_grpc-authority': params["authority"],
108 | };
109 | } else {
110 | if (params["path"]) {
111 | $set(proxy, proxy.network+"-opts.path", decodeURIComponent(params["path"]));
112 | }
113 | if (params["host"]) {
114 | $set(proxy, proxy.network+"-opts.headers.Host", decodeURIComponent(params["host"]));
115 | }
116 | if (httpupgrade) {
117 | $set(proxy, proxy.network+"-opts.v2ray-http-upgrade", true);
118 | $set(proxy, proxy.network+"-opts.v2ray-http-upgrade-fast-open", true);
119 | }
120 | }
121 | if (['reality'].includes(params.security)) {
122 | const opts = {};
123 | if (params.pbk) {
124 | opts['public-key'] = params.pbk;
125 | }
126 | if (params.sid) {
127 | opts['short-id'] = params.sid;
128 | }
129 | if (params.spx) {
130 | opts['_spider-x'] = params.spx;
131 | }
132 | if (params.mode) {
133 | proxy._mode = params.mode;
134 | }
135 | if (params.extra) {
136 | proxy._extra = params.extra;
137 | }
138 | if (Object.keys(opts).length > 0) {
139 | $set(proxy, params.security+"-opts", opts);
140 | }
141 | }
142 | }
143 |
144 | proxy.udp = toBool(params["udp"]);
145 | proxy.tfo = toBool(params["tfo"]);
146 | }
147 |
148 | param = kv/single;
149 |
150 | kv = key:$[a-z]i+ "=" value:$[^]i* {
151 | params[key] = value;
152 | }
153 |
154 | single = key:$[a-z]i+ {
155 | params[key] = true;
156 | };
157 |
158 | name = "#" + match:$.* {
159 | return decodeURIComponent(match);
160 | }
161 | `;
162 | let parser;
163 | export default function getParser() {
164 | if (!parser) {
165 | parser = peggy.generate(grammars);
166 | }
167 | return parser;
168 | }
169 |
--------------------------------------------------------------------------------
/backend/src/core/proxy-utils/parsers/peggy/trojan-uri.peg:
--------------------------------------------------------------------------------
1 | // global initializer
2 | {{
3 | function $set(obj, path, value) {
4 | if (Object(obj) !== obj) return obj;
5 | if (!Array.isArray(path)) path = path.toString().match(/[^.[\]]+/g) || [];
6 | path
7 | .slice(0, -1)
8 | .reduce((a, c, i) => (Object(a[c]) === a[c] ? a[c] : (a[c] = Math.abs(path[i + 1]) >> 0 === +path[i + 1] ? [] : {})), obj)[
9 | path[path.length - 1]
10 | ] = value;
11 | return obj;
12 | }
13 |
14 | function toBool(str) {
15 | if (typeof str === 'undefined' || str === null) return undefined;
16 | return /(TRUE)|1/i.test(str);
17 | }
18 | }}
19 |
20 | {
21 | const proxy = {};
22 | const obfs = {};
23 | const $ = {};
24 | const params = {};
25 | }
26 |
27 | start = (trojan) {
28 | return proxy
29 | }
30 |
31 | trojan = "trojan://" password:password "@" server:server ":" port:port "/"? params? name:name?{
32 | proxy.type = "trojan";
33 | proxy.password = password;
34 | proxy.server = server;
35 | proxy.port = port;
36 | proxy.name = name;
37 |
38 | // name may be empty
39 | if (!proxy.name) {
40 | proxy.name = server + ":" + port;
41 | }
42 | };
43 |
44 | password = match:$[^@]+ {
45 | return decodeURIComponent(match);
46 | };
47 |
48 | server = ip/domain;
49 |
50 | domain = match:[0-9a-zA-z-_.]+ {
51 | const domain = match.join("");
52 | if (/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/.test(domain)) {
53 | return domain;
54 | }
55 | }
56 |
57 | ip = & {
58 | const start = peg$currPos;
59 | let end;
60 | let j = start;
61 | while (j < input.length) {
62 | if (input[j] === ",") break;
63 | if (input[j] === ":") end = j;
64 | j++;
65 | }
66 | peg$currPos = end || j;
67 | $.ip = input.substring(start, end).trim();
68 | return true;
69 | } { return $.ip; }
70 |
71 | port = digits:[0-9]+ {
72 | const port = parseInt(digits.join(""), 10);
73 | if (port >= 0 && port <= 65535) {
74 | return port;
75 | } else {
76 | throw new Error("Invalid port: " + port);
77 | }
78 | }
79 |
80 | params = "?" head:param tail:("&"@param)* {
81 | for (const [key, value] of Object.entries(params)) {
82 | params[key] = decodeURIComponent(value);
83 | }
84 | proxy["skip-cert-verify"] = toBool(params["allowInsecure"]);
85 | proxy.sni = params["sni"] || params["peer"];
86 | proxy['client-fingerprint'] = params.fp;
87 | proxy.alpn = params.alpn ? decodeURIComponent(params.alpn).split(',') : undefined;
88 |
89 | if (toBool(params["ws"])) {
90 | proxy.network = "ws";
91 | $set(proxy, "ws-opts.path", params["wspath"]);
92 | }
93 |
94 | if (params["type"]) {
95 | let httpupgrade
96 | proxy.network = params["type"]
97 | if(proxy.network === 'httpupgrade') {
98 | proxy.network = 'ws'
99 | httpupgrade = true
100 | }
101 | if (['grpc'].includes(proxy.network)) {
102 | proxy[proxy.network + '-opts'] = {
103 | 'grpc-service-name': params["serviceName"],
104 | '_grpc-type': params["mode"],
105 | '_grpc-authority': params["authority"],
106 | };
107 | } else {
108 | if (params["path"]) {
109 | $set(proxy, proxy.network+"-opts.path", decodeURIComponent(params["path"]));
110 | }
111 | if (params["host"]) {
112 | $set(proxy, proxy.network+"-opts.headers.Host", decodeURIComponent(params["host"]));
113 | }
114 | if (httpupgrade) {
115 | $set(proxy, proxy.network+"-opts.v2ray-http-upgrade", true);
116 | $set(proxy, proxy.network+"-opts.v2ray-http-upgrade-fast-open", true);
117 | }
118 | }
119 | if (['reality'].includes(params.security)) {
120 | const opts = {};
121 | if (params.pbk) {
122 | opts['public-key'] = params.pbk;
123 | }
124 | if (params.sid) {
125 | opts['short-id'] = params.sid;
126 | }
127 | if (params.spx) {
128 | opts['_spider-x'] = params.spx;
129 | }
130 | if (params.mode) {
131 | proxy._mode = params.mode;
132 | }
133 | if (params.extra) {
134 | proxy._extra = params.extra;
135 | }
136 | if (Object.keys(opts).length > 0) {
137 | $set(proxy, params.security+"-opts", opts);
138 | }
139 | }
140 | }
141 |
142 | proxy.udp = toBool(params["udp"]);
143 | proxy.tfo = toBool(params["tfo"]);
144 | }
145 |
146 | param = kv/single;
147 |
148 | kv = key:$[a-z]i+ "=" value:$[^]i* {
149 | params[key] = value;
150 | }
151 |
152 | single = key:$[a-z]i+ {
153 | params[key] = true;
154 | };
155 |
156 | name = "#" + match:$.* {
157 | return decodeURIComponent(match);
158 | }
--------------------------------------------------------------------------------
/backend/src/core/proxy-utils/preprocessors/index.js:
--------------------------------------------------------------------------------
1 | import { safeLoad } from '@/utils/yaml';
2 | import { Base64 } from 'js-base64';
3 | import $ from '@/core/app';
4 |
5 | function HTML() {
6 | const name = 'HTML';
7 | const test = (raw) => /^/.test(raw);
8 | // simply discard HTML
9 | const parse = () => '';
10 | return { name, test, parse };
11 | }
12 |
13 | function Base64Encoded() {
14 | const name = 'Base64 Pre-processor';
15 |
16 | const keys = [
17 | 'dm1lc3M', // vmess
18 | 'c3NyOi8v', // ssr://
19 | 'c29ja3M6Ly', // socks://
20 | 'dHJvamFu', // trojan
21 | 'c3M6Ly', // ss:/
22 | 'c3NkOi8v', // ssd://
23 | 'c2hhZG93', // shadow
24 | 'aHR0c', // htt
25 | 'dmxlc3M=', // vless
26 | 'aHlzdGVyaWEy', // hysteria2
27 | 'aHkyOi8v', // hy2://
28 | 'd2lyZWd1YXJkOi8v', // wireguard://
29 | 'd2c6Ly8=', // wg://
30 | 'dHVpYzovLw==', // tuic://
31 | ];
32 |
33 | const test = function (raw) {
34 | return (
35 | !/^\w+:\/\/\w+/im.test(raw) &&
36 | keys.some((k) => raw.indexOf(k) !== -1)
37 | );
38 | };
39 | const parse = function (raw) {
40 | const decoded = Base64.decode(raw);
41 | if (!/^\w+(:\/\/|\s*?=\s*?)\w+/m.test(decoded)) {
42 | $.error(
43 | `Base64 Pre-processor error: decoded line does not start with protocol`,
44 | );
45 | return raw;
46 | }
47 |
48 | return decoded;
49 | };
50 | return { name, test, parse };
51 | }
52 |
53 | function fallbackBase64Encoded() {
54 | const name = 'Fallback Base64 Pre-processor';
55 |
56 | const test = function (raw) {
57 | return true;
58 | };
59 | const parse = function (raw) {
60 | const decoded = Base64.decode(raw);
61 | if (!/^\w+(:\/\/|\s*?=\s*?)\w+/m.test(decoded)) {
62 | $.error(
63 | `Fallback Base64 Pre-processor error: decoded line does not start with protocol`,
64 | );
65 | return raw;
66 | }
67 |
68 | return decoded;
69 | };
70 | return { name, test, parse };
71 | }
72 |
73 | function Clash() {
74 | const name = 'Clash Pre-processor';
75 | const test = function (raw) {
76 | if (!/proxies/.test(raw)) return false;
77 | const content = safeLoad(raw);
78 | return content.proxies && Array.isArray(content.proxies);
79 | };
80 | const parse = function (raw, includeProxies) {
81 | // Clash YAML format
82 |
83 | // 防止 VLESS节点 reality-opts 选项中的 short-id 被解析成 Infinity
84 | // 匹配 short-id 冒号后面的值(包含空格和引号)
85 | const afterReplace = raw.replace(
86 | /short-id:([ \t]*[^#\n,}]*)/g,
87 | (matched, value) => {
88 | const afterTrim = value.trim();
89 |
90 | // 为空
91 | if (!afterTrim || afterTrim === '') {
92 | return 'short-id: ""';
93 | }
94 |
95 | // 是否被引号包裹
96 | if (/^(['"]).*\1$/.test(afterTrim)) {
97 | return `short-id: ${afterTrim}`;
98 | } else if (['null'].includes(afterTrim)) {
99 | return `short-id: ${afterTrim}`;
100 | } else {
101 | return `short-id: "${afterTrim}"`;
102 | }
103 | },
104 | );
105 |
106 | const {
107 | proxies,
108 | 'global-client-fingerprint': globalClientFingerprint,
109 | } = safeLoad(afterReplace);
110 | return (
111 | (includeProxies ? 'proxies:\n' : '') +
112 | proxies
113 | .map((p) => {
114 | // https://github.com/MetaCubeX/mihomo/blob/Alpha/docs/config.yaml#L73C1-L73C26
115 | if (globalClientFingerprint && !p['client-fingerprint']) {
116 | p['client-fingerprint'] = globalClientFingerprint;
117 | }
118 | return `${includeProxies ? ' - ' : ''}${JSON.stringify(
119 | p,
120 | )}\n`;
121 | })
122 | .join('')
123 | );
124 | };
125 | return { name, test, parse };
126 | }
127 |
128 | function SSD() {
129 | const name = 'SSD Pre-processor';
130 | const test = function (raw) {
131 | return raw.indexOf('ssd://') === 0;
132 | };
133 | const parse = function (raw) {
134 | // preprocessing for SSD subscription format
135 | const output = [];
136 | let ssdinfo = JSON.parse(Base64.decode(raw.split('ssd://')[1]));
137 | let port = ssdinfo.port;
138 | let method = ssdinfo.encryption;
139 | let password = ssdinfo.password;
140 | // servers config
141 | let servers = ssdinfo.servers;
142 | for (let i = 0; i < servers.length; i++) {
143 | let server = servers[i];
144 | method = server.encryption ? server.encryption : method;
145 | password = server.password ? server.password : password;
146 | let userinfo = Base64.encode(method + ':' + password);
147 | let hostname = server.server;
148 | port = server.port ? server.port : port;
149 | let tag = server.remarks ? server.remarks : i;
150 | let plugin = server.plugin_options
151 | ? '/?plugin=' +
152 | encodeURIComponent(
153 | server.plugin + ';' + server.plugin_options,
154 | )
155 | : '';
156 | output[i] =
157 | 'ss://' +
158 | userinfo +
159 | '@' +
160 | hostname +
161 | ':' +
162 | port +
163 | plugin +
164 | '#' +
165 | tag;
166 | }
167 | return output.join('\n');
168 | };
169 | return { name, test, parse };
170 | }
171 |
172 | function FullConfig() {
173 | const name = 'Full Config Preprocessor';
174 | const test = function (raw) {
175 | return /^(\[server_local\]|\[Proxy\])/gm.test(raw);
176 | };
177 | const parse = function (raw) {
178 | const match = raw.match(
179 | /^\[server_local|Proxy\]([\s\S]+?)^\[.+?\](\r?\n|$)/im,
180 | )?.[1];
181 | return match || raw;
182 | };
183 | return { name, test, parse };
184 | }
185 |
186 | export default [
187 | HTML(),
188 | Clash(),
189 | Base64Encoded(),
190 | SSD(),
191 | FullConfig(),
192 | fallbackBase64Encoded(),
193 | ];
194 |
--------------------------------------------------------------------------------
/backend/src/core/proxy-utils/producers/clash.js:
--------------------------------------------------------------------------------
1 | import { isPresent } from '@/core/proxy-utils/producers/utils';
2 | import $ from '@/core/app';
3 |
4 | export default function Clash_Producer() {
5 | const type = 'ALL';
6 | const produce = (proxies, type, opts = {}) => {
7 | // VLESS XTLS is not supported by Clash
8 | // https://github.com/MetaCubeX/Clash.Meta/blob/Alpha/docs/config.yaml#L532
9 | // github.com/Dreamacro/clash/pull/2891/files
10 | // filter unsupported proxies
11 | // https://clash.wiki/configuration/outbound.html#shadowsocks
12 | const list = proxies
13 | .filter((proxy) => {
14 | if (opts['include-unsupported-proxy']) return true;
15 | if (
16 | ![
17 | 'ss',
18 | 'ssr',
19 | 'vmess',
20 | 'vless',
21 | 'socks5',
22 | 'http',
23 | 'snell',
24 | 'trojan',
25 | 'wireguard',
26 | ].includes(proxy.type) ||
27 | (proxy.type === 'ss' &&
28 | ![
29 | 'aes-128-gcm',
30 | 'aes-192-gcm',
31 | 'aes-256-gcm',
32 | 'aes-128-cfb',
33 | 'aes-192-cfb',
34 | 'aes-256-cfb',
35 | 'aes-128-ctr',
36 | 'aes-192-ctr',
37 | 'aes-256-ctr',
38 | 'rc4-md5',
39 | 'chacha20-ietf',
40 | 'xchacha20',
41 | 'chacha20-ietf-poly1305',
42 | 'xchacha20-ietf-poly1305',
43 | ].includes(proxy.cipher)) ||
44 | (proxy.type === 'snell' && String(proxy.version) === '4') ||
45 | (proxy.type === 'vless' &&
46 | (typeof proxy.flow !== 'undefined' ||
47 | proxy['reality-opts']))
48 | ) {
49 | return false;
50 | } else if (proxy['underlying-proxy'] || proxy['dialer-proxy']) {
51 | $.error(
52 | `Clash 不支持前置代理字段. 已过滤节点 ${proxy.name}`,
53 | );
54 | return false;
55 | }
56 | return true;
57 | })
58 | .map((proxy) => {
59 | if (proxy.type === 'vmess') {
60 | // handle vmess aead
61 | if (isPresent(proxy, 'aead')) {
62 | if (proxy.aead) {
63 | proxy.alterId = 0;
64 | }
65 | delete proxy.aead;
66 | }
67 | if (isPresent(proxy, 'sni')) {
68 | proxy.servername = proxy.sni;
69 | delete proxy.sni;
70 | }
71 | // https://dreamacro.github.io/clash/configuration/outbound.html#vmess
72 | if (
73 | isPresent(proxy, 'cipher') &&
74 | ![
75 | 'auto',
76 | 'aes-128-gcm',
77 | 'chacha20-poly1305',
78 | 'none',
79 | ].includes(proxy.cipher)
80 | ) {
81 | proxy.cipher = 'auto';
82 | }
83 | } else if (proxy.type === 'wireguard') {
84 | proxy.keepalive =
85 | proxy.keepalive ?? proxy['persistent-keepalive'];
86 | proxy['persistent-keepalive'] = proxy.keepalive;
87 | proxy['preshared-key'] =
88 | proxy['preshared-key'] ?? proxy['pre-shared-key'];
89 | proxy['pre-shared-key'] = proxy['preshared-key'];
90 | } else if (proxy.type === 'snell' && proxy.version < 3) {
91 | delete proxy.udp;
92 | } else if (proxy.type === 'vless') {
93 | if (isPresent(proxy, 'sni')) {
94 | proxy.servername = proxy.sni;
95 | delete proxy.sni;
96 | }
97 | }
98 |
99 | if (
100 | ['vmess', 'vless'].includes(proxy.type) &&
101 | proxy.network === 'http'
102 | ) {
103 | let httpPath = proxy['http-opts']?.path;
104 | if (
105 | isPresent(proxy, 'http-opts.path') &&
106 | !Array.isArray(httpPath)
107 | ) {
108 | proxy['http-opts'].path = [httpPath];
109 | }
110 | let httpHost = proxy['http-opts']?.headers?.Host;
111 | if (
112 | isPresent(proxy, 'http-opts.headers.Host') &&
113 | !Array.isArray(httpHost)
114 | ) {
115 | proxy['http-opts'].headers.Host = [httpHost];
116 | }
117 | }
118 | if (
119 | ['vmess', 'vless'].includes(proxy.type) &&
120 | proxy.network === 'h2'
121 | ) {
122 | let path = proxy['h2-opts']?.path;
123 | if (
124 | isPresent(proxy, 'h2-opts.path') &&
125 | Array.isArray(path)
126 | ) {
127 | proxy['h2-opts'].path = path[0];
128 | }
129 | let host = proxy['h2-opts']?.headers?.host;
130 | if (
131 | isPresent(proxy, 'h2-opts.headers.Host') &&
132 | !Array.isArray(host)
133 | ) {
134 | proxy['h2-opts'].headers.host = [host];
135 | }
136 | }
137 | if (proxy['plugin-opts']?.tls) {
138 | if (isPresent(proxy, 'skip-cert-verify')) {
139 | proxy['plugin-opts']['skip-cert-verify'] =
140 | proxy['skip-cert-verify'];
141 | }
142 | }
143 | if (
144 | [
145 | 'trojan',
146 | 'tuic',
147 | 'hysteria',
148 | 'hysteria2',
149 | 'juicity',
150 | 'anytls',
151 | ].includes(proxy.type)
152 | ) {
153 | delete proxy.tls;
154 | }
155 |
156 | if (proxy['tls-fingerprint']) {
157 | proxy.fingerprint = proxy['tls-fingerprint'];
158 | }
159 | delete proxy['tls-fingerprint'];
160 |
161 | if (isPresent(proxy, 'tls') && typeof proxy.tls !== 'boolean') {
162 | delete proxy.tls;
163 | }
164 |
165 | delete proxy.subName;
166 | delete proxy.collectionName;
167 | delete proxy.id;
168 | delete proxy.resolved;
169 | delete proxy['no-resolve'];
170 | if (type !== 'internal') {
171 | for (const key in proxy) {
172 | if (proxy[key] == null || /^_/i.test(key)) {
173 | delete proxy[key];
174 | }
175 | }
176 | }
177 | if (
178 | ['grpc'].includes(proxy.network) &&
179 | proxy[`${proxy.network}-opts`]
180 | ) {
181 | delete proxy[`${proxy.network}-opts`]['_grpc-type'];
182 | delete proxy[`${proxy.network}-opts`]['_grpc-authority'];
183 | }
184 | return proxy;
185 | });
186 | return type === 'internal'
187 | ? list
188 | : 'proxies:\n' +
189 | list
190 | .map((proxy) => ' - ' + JSON.stringify(proxy) + '\n')
191 | .join('');
192 | };
193 | return { type, produce };
194 | }
195 |
--------------------------------------------------------------------------------
/backend/src/core/proxy-utils/producers/index.js:
--------------------------------------------------------------------------------
1 | import Surge_Producer from './surge';
2 | import SurgeMac_Producer from './surgemac';
3 | import Clash_Producer from './clash';
4 | import ClashMeta_Producer from './clashmeta';
5 | import Stash_Producer from './stash';
6 | import Loon_Producer from './loon';
7 | import URI_Producer from './uri';
8 | import V2Ray_Producer from './v2ray';
9 | import QX_Producer from './qx';
10 | import Shadowrocket_Producer from './shadowrocket';
11 | import Surfboard_Producer from './surfboard';
12 | import singbox_Producer from './sing-box';
13 | import Egern_Producer from './egern';
14 |
15 | function JSON_Producer() {
16 | const type = 'ALL';
17 | const produce = (proxies, type) =>
18 | type === 'internal' ? proxies : JSON.stringify(proxies, null, 2);
19 | return { type, produce };
20 | }
21 |
22 | export default {
23 | qx: QX_Producer(),
24 | QX: QX_Producer(),
25 | QuantumultX: QX_Producer(),
26 | surge: Surge_Producer(),
27 | Surge: Surge_Producer(),
28 | SurgeMac: SurgeMac_Producer(),
29 | Loon: Loon_Producer(),
30 | Clash: Clash_Producer(),
31 | meta: ClashMeta_Producer(),
32 | clashmeta: ClashMeta_Producer(),
33 | 'clash.meta': ClashMeta_Producer(),
34 | 'Clash.Meta': ClashMeta_Producer(),
35 | ClashMeta: ClashMeta_Producer(),
36 | mihomo: ClashMeta_Producer(),
37 | Mihomo: ClashMeta_Producer(),
38 | uri: URI_Producer(),
39 | URI: URI_Producer(),
40 | v2: V2Ray_Producer(),
41 | v2ray: V2Ray_Producer(),
42 | V2Ray: V2Ray_Producer(),
43 | json: JSON_Producer(),
44 | JSON: JSON_Producer(),
45 | stash: Stash_Producer(),
46 | Stash: Stash_Producer(),
47 | shadowrocket: Shadowrocket_Producer(),
48 | Shadowrocket: Shadowrocket_Producer(),
49 | ShadowRocket: Shadowrocket_Producer(),
50 | surfboard: Surfboard_Producer(),
51 | Surfboard: Surfboard_Producer(),
52 | singbox: singbox_Producer(),
53 | 'sing-box': singbox_Producer(),
54 | egern: Egern_Producer(),
55 | Egern: Egern_Producer(),
56 | };
57 |
--------------------------------------------------------------------------------
/backend/src/core/proxy-utils/producers/surfboard.js:
--------------------------------------------------------------------------------
1 | import { Result, isPresent } from './utils';
2 | import { isNotBlank } from '@/utils';
3 | // import $ from '@/core/app';
4 |
5 | const targetPlatform = 'Surfboard';
6 |
7 | export default function Surfboard_Producer() {
8 | const produce = (proxy) => {
9 | proxy.name = proxy.name.replace(/=|,/g, '');
10 | switch (proxy.type) {
11 | case 'ss':
12 | return shadowsocks(proxy);
13 | case 'trojan':
14 | return trojan(proxy);
15 | case 'vmess':
16 | return vmess(proxy);
17 | case 'http':
18 | return http(proxy);
19 | case 'socks5':
20 | return socks5(proxy);
21 | case 'wireguard-surge':
22 | return wireguard(proxy);
23 | }
24 | throw new Error(
25 | `Platform ${targetPlatform} does not support proxy type: ${proxy.type}`,
26 | );
27 | };
28 | return { produce };
29 | }
30 |
31 | function shadowsocks(proxy) {
32 | const result = new Result(proxy);
33 | result.append(`${proxy.name}=${proxy.type},${proxy.server},${proxy.port}`);
34 | if (
35 | ![
36 | 'aes-128-gcm',
37 | 'aes-192-gcm',
38 | 'aes-256-gcm',
39 | 'chacha20-ietf-poly1305',
40 | 'xchacha20-ietf-poly1305',
41 | 'rc4',
42 | 'rc4-md5',
43 | 'aes-128-cfb',
44 | 'aes-192-cfb',
45 | 'aes-256-cfb',
46 | 'aes-128-ctr',
47 | 'aes-192-ctr',
48 | 'aes-256-ctr',
49 | 'bf-cfb',
50 | 'camellia-128-cfb',
51 | 'camellia-192-cfb',
52 | 'camellia-256-cfb',
53 | 'salsa20',
54 | 'chacha20',
55 | 'chacha20-ietf',
56 | ].includes(proxy.cipher)
57 | ) {
58 | throw new Error(`cipher ${proxy.cipher} is not supported`);
59 | }
60 | result.append(`,encrypt-method=${proxy.cipher}`);
61 | result.appendIfPresent(`,password=${proxy.password}`, 'password');
62 |
63 | // obfs
64 | if (isPresent(proxy, 'plugin')) {
65 | if (proxy.plugin === 'obfs') {
66 | result.append(`,obfs=${proxy['plugin-opts'].mode}`);
67 | result.appendIfPresent(
68 | `,obfs-host=${proxy['plugin-opts'].host}`,
69 | 'plugin-opts.host',
70 | );
71 | result.appendIfPresent(
72 | `,obfs-uri=${proxy['plugin-opts'].path}`,
73 | 'plugin-opts.path',
74 | );
75 | } else {
76 | throw new Error(`plugin ${proxy.plugin} is not supported`);
77 | }
78 | }
79 |
80 | // udp
81 | result.appendIfPresent(`,udp-relay=${proxy.udp}`, 'udp');
82 |
83 | return result.toString();
84 | }
85 |
86 | function trojan(proxy) {
87 | const result = new Result(proxy);
88 | result.append(`${proxy.name}=${proxy.type},${proxy.server},${proxy.port}`);
89 | result.appendIfPresent(`,password=${proxy.password}`, 'password');
90 |
91 | // transport
92 | handleTransport(result, proxy);
93 |
94 | // tls
95 | result.appendIfPresent(`,tls=${proxy.tls}`, 'tls');
96 |
97 | // tls verification
98 | result.appendIfPresent(`,sni=${proxy.sni}`, 'sni');
99 | result.appendIfPresent(
100 | `,skip-cert-verify=${proxy['skip-cert-verify']}`,
101 | 'skip-cert-verify',
102 | );
103 |
104 | // tfo
105 | result.appendIfPresent(`,tfo=${proxy.tfo}`, 'tfo');
106 |
107 | // udp
108 | result.appendIfPresent(`,udp-relay=${proxy.udp}`, 'udp');
109 |
110 | return result.toString();
111 | }
112 |
113 | function vmess(proxy) {
114 | const result = new Result(proxy);
115 | result.append(`${proxy.name}=${proxy.type},${proxy.server},${proxy.port}`);
116 | result.appendIfPresent(`,username=${proxy.uuid}`, 'uuid');
117 |
118 | // transport
119 | handleTransport(result, proxy);
120 |
121 | // AEAD
122 | if (isPresent(proxy, 'aead')) {
123 | result.append(`,vmess-aead=${proxy.aead}`);
124 | } else {
125 | result.append(`,vmess-aead=${proxy.alterId === 0}`);
126 | }
127 |
128 | // tls
129 | result.appendIfPresent(`,tls=${proxy.tls}`, 'tls');
130 |
131 | // tls verification
132 | result.appendIfPresent(`,sni=${proxy.sni}`, 'sni');
133 | result.appendIfPresent(
134 | `,skip-cert-verify=${proxy['skip-cert-verify']}`,
135 | 'skip-cert-verify',
136 | );
137 |
138 | // udp
139 | result.appendIfPresent(`,udp-relay=${proxy.udp}`, 'udp');
140 |
141 | return result.toString();
142 | }
143 |
144 | function http(proxy) {
145 | const result = new Result(proxy);
146 | const type = proxy.tls ? 'https' : 'http';
147 | result.append(`${proxy.name}=${type},${proxy.server},${proxy.port}`);
148 | result.appendIfPresent(`,${proxy.username}`, 'username');
149 | result.appendIfPresent(`,${proxy.password}`, 'password');
150 |
151 | // tls verification
152 | result.appendIfPresent(`,sni=${proxy.sni}`, 'sni');
153 | result.appendIfPresent(
154 | `,skip-cert-verify=${proxy['skip-cert-verify']}`,
155 | 'skip-cert-verify',
156 | );
157 |
158 | // udp
159 | result.appendIfPresent(`,udp-relay=${proxy.udp}`, 'udp');
160 |
161 | return result.toString();
162 | }
163 |
164 | function socks5(proxy) {
165 | const result = new Result(proxy);
166 | const type = proxy.tls ? 'socks5-tls' : 'socks5';
167 | result.append(`${proxy.name}=${type},${proxy.server},${proxy.port}`);
168 | result.appendIfPresent(`,${proxy.username}`, 'username');
169 | result.appendIfPresent(`,${proxy.password}`, 'password');
170 |
171 | // tls verification
172 | result.appendIfPresent(`,sni=${proxy.sni}`, 'sni');
173 | result.appendIfPresent(
174 | `,skip-cert-verify=${proxy['skip-cert-verify']}`,
175 | 'skip-cert-verify',
176 | );
177 |
178 | // udp
179 | result.appendIfPresent(`,udp-relay=${proxy.udp}`, 'udp');
180 |
181 | return result.toString();
182 | }
183 |
184 | function wireguard(proxy) {
185 | const result = new Result(proxy);
186 |
187 | result.append(`${proxy.name}=wireguard`);
188 |
189 | result.appendIfPresent(
190 | `,section-name=${proxy['section-name']}`,
191 | 'section-name',
192 | );
193 |
194 | return result.toString();
195 | }
196 |
197 | function handleTransport(result, proxy) {
198 | if (isPresent(proxy, 'network')) {
199 | if (proxy.network === 'ws') {
200 | result.append(`,ws=true`);
201 | if (isPresent(proxy, 'ws-opts')) {
202 | result.appendIfPresent(
203 | `,ws-path=${proxy['ws-opts'].path}`,
204 | 'ws-opts.path',
205 | );
206 | if (isPresent(proxy, 'ws-opts.headers')) {
207 | const headers = proxy['ws-opts'].headers;
208 | const value = Object.keys(headers)
209 | .map((k) => {
210 | let v = headers[k];
211 | if (['Host'].includes(k)) {
212 | v = `"${v}"`;
213 | }
214 | return `${k}:${v}`;
215 | })
216 | .join('|');
217 | if (isNotBlank(value)) {
218 | result.append(`,ws-headers=${value}`);
219 | }
220 | }
221 | }
222 | } else {
223 | throw new Error(`network ${proxy.network} is unsupported`);
224 | }
225 | }
226 | }
227 |
--------------------------------------------------------------------------------
/backend/src/core/proxy-utils/producers/surgemac.js:
--------------------------------------------------------------------------------
1 | import { Base64 } from 'js-base64';
2 | import { Result, isPresent } from './utils';
3 | import Surge_Producer from './surge';
4 | import ClashMeta_Producer from './clashmeta';
5 | import { isIPv4, isIPv6 } from '@/utils';
6 | import $ from '@/core/app';
7 |
8 | const targetPlatform = 'SurgeMac';
9 |
10 | const surge_Producer = Surge_Producer();
11 |
12 | export default function SurgeMac_Producer() {
13 | const produce = (proxy, type, opts = {}) => {
14 | switch (proxy.type) {
15 | case 'external':
16 | return external(proxy);
17 | // case 'ssr':
18 | // return shadowsocksr(proxy);
19 | default: {
20 | try {
21 | return surge_Producer.produce(proxy, type, opts);
22 | } catch (e) {
23 | if (opts.useMihomoExternal) {
24 | $.log(
25 | `${proxy.name} is not supported on ${targetPlatform}, try to use Mihomo(SurgeMac - External Proxy Program) instead`,
26 | );
27 | return mihomo(proxy, type, opts);
28 | } else {
29 | throw new Error(
30 | `Surge for macOS 可手动指定链接参数 target=SurgeMac 或在 同步配置 中指定 SurgeMac 来启用 mihomo 支援 Surge 本身不支持的协议`,
31 | );
32 | }
33 | }
34 | }
35 | }
36 | };
37 | return { produce };
38 | }
39 | function external(proxy) {
40 | const result = new Result(proxy);
41 | if (!proxy.exec || !proxy['local-port']) {
42 | throw new Error(`${proxy.type}: exec and local-port are required`);
43 | }
44 | result.append(
45 | `${proxy.name}=external,exec="${proxy.exec}",local-port=${proxy['local-port']}`,
46 | );
47 |
48 | if (Array.isArray(proxy.args)) {
49 | proxy.args.map((args) => {
50 | result.append(`,args="${args}"`);
51 | });
52 | }
53 | if (Array.isArray(proxy.addresses)) {
54 | proxy.addresses.map((addresses) => {
55 | result.append(`,addresses=${addresses}`);
56 | });
57 | }
58 |
59 | result.appendIfPresent(
60 | `,no-error-alert=${proxy['no-error-alert']}`,
61 | 'no-error-alert',
62 | );
63 |
64 | // tfo
65 | if (isPresent(proxy, 'tfo')) {
66 | result.append(`,tfo=${proxy['tfo']}`);
67 | } else if (isPresent(proxy, 'fast-open')) {
68 | result.append(`,tfo=${proxy['fast-open']}`);
69 | }
70 |
71 | // test-url
72 | result.appendIfPresent(`,test-url=${proxy['test-url']}`, 'test-url');
73 |
74 | // block-quic
75 | result.appendIfPresent(`,block-quic=${proxy['block-quic']}`, 'block-quic');
76 |
77 | return result.toString();
78 | }
79 | // eslint-disable-next-line no-unused-vars
80 | function shadowsocksr(proxy) {
81 | const external_proxy = {
82 | ...proxy,
83 | type: 'external',
84 | exec: proxy.exec || '/usr/local/bin/ssr-local',
85 | 'local-port': '__SubStoreLocalPort__',
86 | args: [],
87 | addresses: [],
88 | 'local-address':
89 | proxy.local_address ?? proxy['local-address'] ?? '127.0.0.1',
90 | };
91 |
92 | // https://manual.nssurge.com/policy/external-proxy.html
93 | if (isIP(proxy.server)) {
94 | external_proxy.addresses.push(proxy.server);
95 | } else {
96 | $.log(
97 | `Platform ${targetPlatform}, proxy type ${proxy.type}: addresses should be an IP address, but got ${proxy.server}`,
98 | );
99 | }
100 |
101 | for (const [key, value] of Object.entries({
102 | cipher: '-m',
103 | obfs: '-o',
104 | 'obfs-param': '-g',
105 | password: '-k',
106 | port: '-p',
107 | protocol: '-O',
108 | 'protocol-param': '-G',
109 | server: '-s',
110 | 'local-port': '-l',
111 | 'local-address': '-b',
112 | })) {
113 | if (external_proxy[key] != null) {
114 | external_proxy.args.push(value);
115 | external_proxy.args.push(external_proxy[key]);
116 | }
117 | }
118 |
119 | return external(external_proxy);
120 | }
121 | // eslint-disable-next-line no-unused-vars
122 | function mihomo(proxy, type, opts) {
123 | const clashProxy = ClashMeta_Producer().produce([proxy], 'internal')?.[0];
124 | if (clashProxy) {
125 | const localPort = opts?.localPort || proxy._localPort || 65535;
126 | const ipv6 = ['ipv4', 'v4-only'].includes(proxy['ip-version'])
127 | ? false
128 | : true;
129 | const external_proxy = {
130 | name: proxy.name,
131 | type: 'external',
132 | exec: proxy._exec || '/usr/local/bin/mihomo',
133 | 'local-port': localPort,
134 | args: [
135 | '-config',
136 | Base64.encode(
137 | JSON.stringify({
138 | 'mixed-port': localPort,
139 | ipv6,
140 | mode: 'global',
141 | dns: {
142 | enable: true,
143 | ipv6,
144 | 'default-nameserver': opts?.defaultNameserver ||
145 | proxy._defaultNameserver || [
146 | '180.76.76.76',
147 | '52.80.52.52',
148 | '119.28.28.28',
149 | '223.6.6.6',
150 | ],
151 | nameserver: opts?.nameserver ||
152 | proxy._nameserver || [
153 | 'https://doh.pub/dns-query',
154 | 'https://dns.alidns.com/dns-query',
155 | 'https://doh-pure.onedns.net/dns-query',
156 | ],
157 | },
158 | proxies: [
159 | {
160 | ...clashProxy,
161 | name: 'proxy',
162 | },
163 | ],
164 | 'proxy-groups': [
165 | {
166 | name: 'GLOBAL',
167 | type: 'select',
168 | proxies: ['proxy'],
169 | },
170 | ],
171 | }),
172 | ),
173 | ],
174 | addresses: [],
175 | };
176 |
177 | // https://manual.nssurge.com/policy/external-proxy.html
178 | if (isIP(proxy.server)) {
179 | external_proxy.addresses.push(proxy.server);
180 | } else {
181 | $.log(
182 | `Platform ${targetPlatform}, proxy type ${proxy.type}: addresses should be an IP address, but got ${proxy.server}`,
183 | );
184 | }
185 | opts.localPort = localPort - 1;
186 | return external(external_proxy);
187 | }
188 | }
189 |
190 | function isIP(ip) {
191 | return isIPv4(ip) || isIPv6(ip);
192 | }
193 |
--------------------------------------------------------------------------------
/backend/src/core/proxy-utils/producers/utils.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 |
3 | export class Result {
4 | constructor(proxy) {
5 | this.proxy = proxy;
6 | this.output = [];
7 | }
8 |
9 | append(data) {
10 | if (typeof data === 'undefined') {
11 | throw new Error('required field is missing');
12 | }
13 | this.output.push(data);
14 | }
15 |
16 | appendIfPresent(data, attr) {
17 | if (isPresent(this.proxy, attr)) {
18 | this.append(data);
19 | }
20 | }
21 |
22 | toString() {
23 | return this.output.join('');
24 | }
25 | }
26 |
27 | export function isPresent(obj, attr) {
28 | const data = _.get(obj, attr);
29 | return typeof data !== 'undefined' && data !== null;
30 | }
31 |
--------------------------------------------------------------------------------
/backend/src/core/proxy-utils/producers/v2ray.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-case-declarations */
2 | import { Base64 } from 'js-base64';
3 | import URI_Producer from './uri';
4 | import $ from '@/core/app';
5 |
6 | const URI = URI_Producer();
7 |
8 | export default function V2Ray_Producer() {
9 | const type = 'ALL';
10 | const produce = (proxies) => {
11 | let result = [];
12 | proxies.map((proxy) => {
13 | try {
14 | result.push(URI.produce(proxy));
15 | } catch (err) {
16 | $.error(
17 | `Cannot produce proxy: ${JSON.stringify(
18 | proxy,
19 | null,
20 | 2,
21 | )}\nReason: ${err}`,
22 | );
23 | }
24 | });
25 |
26 | return Base64.encode(result.join('\n'));
27 | };
28 |
29 | return { type, produce };
30 | }
31 |
--------------------------------------------------------------------------------
/backend/src/core/proxy-utils/validators/index.js:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sub-store-org/Sub-Store/4aafdaaddbc7ba24651501eaaa25da69219084d9/backend/src/core/proxy-utils/validators/index.js
--------------------------------------------------------------------------------
/backend/src/core/rule-utils/index.js:
--------------------------------------------------------------------------------
1 | import RULE_PREPROCESSORS from './preprocessors';
2 | import RULE_PRODUCERS from './producers';
3 | import RULE_PARSERS from './parsers';
4 | import $ from '@/core/app';
5 |
6 | export const RuleUtils = (function () {
7 | function preprocess(raw) {
8 | for (const processor of RULE_PREPROCESSORS) {
9 | try {
10 | if (processor.test(raw)) {
11 | $.info(`Pre-processor [${processor.name}] activated`);
12 | return processor.parse(raw);
13 | }
14 | } catch (e) {
15 | $.error(`Parser [${processor.name}] failed\n Reason: ${e}`);
16 | }
17 | }
18 | return raw;
19 | }
20 |
21 | function parse(raw) {
22 | raw = preprocess(raw);
23 | for (const parser of RULE_PARSERS) {
24 | let matched;
25 | try {
26 | matched = parser.test(raw);
27 | } catch (err) {
28 | matched = false;
29 | }
30 | if (matched) {
31 | $.info(`Rule parser [${parser.name}] is activated!`);
32 | return parser.parse(raw);
33 | }
34 | }
35 | }
36 |
37 | function produce(rules, targetPlatform) {
38 | const producer = RULE_PRODUCERS[targetPlatform];
39 | if (!producer) {
40 | throw new Error(
41 | `Target platform: ${targetPlatform} is not supported!`,
42 | );
43 | }
44 | if (
45 | typeof producer.type === 'undefined' ||
46 | producer.type === 'SINGLE'
47 | ) {
48 | return rules
49 | .map((rule) => {
50 | try {
51 | return producer.func(rule);
52 | } catch (err) {
53 | console.log(
54 | `ERROR: cannot produce rule: ${JSON.stringify(
55 | rule,
56 | )}\nReason: ${err}`,
57 | );
58 | return '';
59 | }
60 | })
61 | .filter((line) => line.length > 0)
62 | .join('\n');
63 | } else if (producer.type === 'ALL') {
64 | return producer.func(rules);
65 | }
66 | }
67 |
68 | return { parse, produce };
69 | })();
70 |
--------------------------------------------------------------------------------
/backend/src/core/rule-utils/parsers.js:
--------------------------------------------------------------------------------
1 | const RULE_TYPES_MAPPING = [
2 | [/^(DOMAIN|host|HOST)$/, 'DOMAIN'],
3 | [/^(DOMAIN-KEYWORD|host-keyword|HOST-KEYWORD)$/, 'DOMAIN-KEYWORD'],
4 | [/^(DOMAIN-SUFFIX|host-suffix|HOST-SUFFIX)$/, 'DOMAIN-SUFFIX'],
5 | [/^USER-AGENT$/i, 'USER-AGENT'],
6 | [/^PROCESS-NAME$/, 'PROCESS-NAME'],
7 | [/^(DEST-PORT|DST-PORT)$/, 'DST-PORT'],
8 | [/^SRC-IP(-CIDR)?$/, 'SRC-IP'],
9 | [/^(IN|SRC)-PORT$/, 'IN-PORT'],
10 | [/^PROTOCOL$/, 'PROTOCOL'],
11 | [/^IP-CIDR$/i, 'IP-CIDR'],
12 | [/^(IP-CIDR6|ip6-cidr|IP6-CIDR)$/, 'IP-CIDR6'],
13 | [/^GEOIP$/i, 'GEOIP'],
14 | [/^GEOSITE$/i, 'GEOSITE'],
15 | ];
16 |
17 | function AllRuleParser() {
18 | const name = 'Universal Rule Parser';
19 | const test = () => true;
20 | const parse = (raw) => {
21 | const lines = raw.split('\n');
22 | const result = [];
23 | for (let line of lines) {
24 | line = line.trim();
25 | // skip empty line
26 | if (line.length === 0) continue;
27 | // skip comments
28 | if (/\s*#/.test(line)) continue;
29 | try {
30 | const params = line.split(',').map((w) => w.trim());
31 | let rawType = params[0];
32 | let matched = false;
33 | for (const item of RULE_TYPES_MAPPING) {
34 | const regex = item[0];
35 | if (regex.test(rawType)) {
36 | matched = true;
37 | const rule = {
38 | type: item[1],
39 | content: params[1],
40 | };
41 | if (
42 | ['IP-CIDR', 'IP-CIDR6', 'GEOIP'].includes(rule.type)
43 | ) {
44 | rule.options = params.slice(2);
45 | }
46 | result.push(rule);
47 | }
48 | }
49 | if (!matched) throw new Error('Invalid rule type: ' + rawType);
50 | } catch (e) {
51 | console.log(`Failed to parse line: ${line}\n Reason: ${e}`);
52 | }
53 | }
54 | return result;
55 | };
56 | return { name, test, parse };
57 | }
58 |
59 | export default [AllRuleParser()];
60 |
--------------------------------------------------------------------------------
/backend/src/core/rule-utils/preprocessors.js:
--------------------------------------------------------------------------------
1 | function HTML() {
2 | const name = 'HTML';
3 | const test = (raw) => /^/.test(raw);
4 | // simply discard HTML
5 | const parse = () => '';
6 | return { name, test, parse };
7 | }
8 |
9 | function ClashProvider() {
10 | const name = 'Clash Provider';
11 | const test = (raw) => /^payload:/gm.exec(raw).index >= 0;
12 | const parse = (raw) => {
13 | return raw.replace('payload:', '').replace(/^\s*-\s*/gm, '');
14 | };
15 | return { name, test, parse };
16 | }
17 |
18 | export default [HTML(), ClashProvider()];
19 |
--------------------------------------------------------------------------------
/backend/src/core/rule-utils/producers.js:
--------------------------------------------------------------------------------
1 | import YAML from '@/utils/yaml';
2 |
3 | function QXFilter() {
4 | const type = 'SINGLE';
5 | const func = (rule) => {
6 | // skip unsupported rules
7 | const UNSUPPORTED = [
8 | 'URL-REGEX',
9 | 'DEST-PORT',
10 | 'SRC-IP',
11 | 'IN-PORT',
12 | 'PROTOCOL',
13 | 'GEOSITE',
14 | 'GEOIP',
15 | ];
16 | if (UNSUPPORTED.indexOf(rule.type) !== -1) return null;
17 |
18 | const TRANSFORM = {
19 | 'DOMAIN-KEYWORD': 'HOST-KEYWORD',
20 | 'DOMAIN-SUFFIX': 'HOST-SUFFIX',
21 | DOMAIN: 'HOST',
22 | 'IP-CIDR6': 'IP6-CIDR',
23 | };
24 |
25 | // QX does not support the no-resolve option
26 | return `${TRANSFORM[rule.type] || rule.type},${rule.content},SUB-STORE`;
27 | };
28 | return { type, func };
29 | }
30 |
31 | function SurgeRuleSet() {
32 | const type = 'SINGLE';
33 | const func = (rule) => {
34 | const UNSUPPORTED = ['GEOSITE', 'GEOIP'];
35 | if (UNSUPPORTED.indexOf(rule.type) !== -1) return null;
36 | let output = `${rule.type},${rule.content}`;
37 | if (['IP-CIDR', 'IP-CIDR6'].includes(rule.type)) {
38 | output +=
39 | rule.options?.length > 0 ? `,${rule.options.join(',')}` : '';
40 | }
41 | return output;
42 | };
43 | return { type, func };
44 | }
45 |
46 | function LoonRules() {
47 | const type = 'SINGLE';
48 | const func = (rule) => {
49 | // skip unsupported rules
50 | const UNSUPPORTED = ['SRC-IP', 'GEOSITE', 'GEOIP'];
51 | if (UNSUPPORTED.indexOf(rule.type) !== -1) return null;
52 | if (['IP-CIDR', 'IP-CIDR6'].includes(rule.type) && rule.options) {
53 | // Loon only supports the no-resolve option
54 | rule.options = rule.options.filter((option) =>
55 | ['no-resolve'].includes(option),
56 | );
57 | }
58 | return SurgeRuleSet().func(rule);
59 | };
60 | return { type, func };
61 | }
62 |
63 | function ClashRuleProvider() {
64 | const type = 'ALL';
65 | const func = (rules) => {
66 | const TRANSFORM = {
67 | 'DEST-PORT': 'DST-PORT',
68 | 'SRC-IP': 'SRC-IP-CIDR',
69 | 'IN-PORT': 'SRC-PORT',
70 | };
71 | const conf = {
72 | payload: rules.map((rule) => {
73 | let output = `${TRANSFORM[rule.type] || rule.type},${
74 | rule.content
75 | }`;
76 | if (['IP-CIDR', 'IP-CIDR6', 'GEOIP'].includes(rule.type)) {
77 | if (rule.options) {
78 | // Clash only supports the no-resolve option
79 | rule.options = rule.options.filter((option) =>
80 | ['no-resolve'].includes(option),
81 | );
82 | }
83 | output +=
84 | rule.options?.length > 0
85 | ? `,${rule.options.join(',')}`
86 | : '';
87 | }
88 | return output;
89 | }),
90 | };
91 | return YAML.dump(conf);
92 | };
93 | return { type, func };
94 | }
95 |
96 | export default {
97 | QX: QXFilter(),
98 | Surge: SurgeRuleSet(),
99 | Loon: LoonRules(),
100 | Clash: ClashRuleProvider(),
101 | };
102 |
--------------------------------------------------------------------------------
/backend/src/main.js:
--------------------------------------------------------------------------------
1 | /**
2 | * ███████╗██╗ ██╗██████╗ ███████╗████████╗ ██████╗ ██████╗ ███████╗
3 | * ██╔════╝██║ ██║██╔══██╗ ██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗██╔════╝
4 | * ███████╗██║ ██║██████╔╝█████╗███████╗ ██║ ██║ ██║██████╔╝█████╗
5 | * ╚════██║██║ ██║██╔══██╗╚════╝╚════██║ ██║ ██║ ██║██╔══██╗██╔══╝
6 | * ███████║╚██████╔╝██████╔╝ ███████║ ██║ ╚██████╔╝██║ ██║███████╗
7 | * ╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝
8 | * Advanced Subscription Manager for QX, Loon, Surge and Clash.
9 | * @author: Peng-YM
10 | * @github: https://github.com/sub-store-org/Sub-Store
11 | * @documentation: https://www.notion.so/Sub-Store-6259586994d34c11a4ced5c406264b46
12 | */
13 | import { version } from '../package.json';
14 | import $ from '@/core/app';
15 | console.log(
16 | `
17 | ┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅
18 | Sub-Store -- v${version}
19 | ┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅
20 | `,
21 | );
22 | import migrate from '@/utils/migration';
23 | import serve from '@/restful';
24 |
25 | migrate();
26 | serve();
27 |
--------------------------------------------------------------------------------
/backend/src/products/resource-parser.loon.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | import { ProxyUtils } from '@/core/proxy-utils';
3 | import { RuleUtils } from '@/core/rule-utils';
4 | import { version } from '../../package.json';
5 | import download from '@/utils/download';
6 |
7 | let result = '';
8 | let resource = typeof $resource !== 'undefined' ? $resource : '';
9 | let resourceType = typeof $resourceType !== 'undefined' ? $resourceType : '';
10 | let resourceUrl = typeof $resourceUrl !== 'undefined' ? $resourceUrl : '';
11 |
12 | !(async () => {
13 | console.log(
14 | `
15 | ┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅
16 | Sub-Store -- v${version}
17 | Loon -- ${$loon}
18 | ┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅
19 | `,
20 | );
21 |
22 | const build = $loon.match(/\((\d+)\)$/)?.[1];
23 | let arg;
24 | if (typeof $argument != 'undefined') {
25 | arg = Object.fromEntries(
26 | $argument.split('&').map((item) => item.split('=')),
27 | );
28 | } else {
29 | arg = {};
30 | }
31 | console.log(`arg: ${JSON.stringify(arg)}`);
32 |
33 | const RESOURCE_TYPE = {
34 | PROXY: 1,
35 | RULE: 2,
36 | };
37 | if (!arg.resourceUrlOnly) {
38 | result = resource;
39 | }
40 |
41 | if (resourceType === RESOURCE_TYPE.PROXY) {
42 | if (!arg.resourceUrlOnly) {
43 | try {
44 | let proxies = ProxyUtils.parse(resource);
45 | result = ProxyUtils.produce(proxies, 'Loon', undefined, {
46 | 'include-unsupported-proxy':
47 | arg?.includeUnsupportedProxy || build >= 842,
48 | });
49 | } catch (e) {
50 | console.log('解析器: 使用 resource 出现错误');
51 | console.log(e.message ?? e);
52 | }
53 | }
54 | if ((!result || /^\s*$/.test(result)) && resourceUrl) {
55 | console.log(`解析器: 尝试从 ${resourceUrl} 获取订阅`);
56 | try {
57 | let raw = await download(
58 | resourceUrl,
59 | arg?.ua,
60 | arg?.timeout,
61 | undefined,
62 | undefined,
63 | undefined,
64 | undefined,
65 | true,
66 | );
67 | let proxies = ProxyUtils.parse(raw);
68 | result = ProxyUtils.produce(proxies, 'Loon', undefined, {
69 | 'include-unsupported-proxy':
70 | arg?.includeUnsupportedProxy || build >= 842,
71 | });
72 | } catch (e) {
73 | console.log(e.message ?? e);
74 | }
75 | }
76 | } else if (resourceType === RESOURCE_TYPE.RULE) {
77 | if (!arg.resourceUrlOnly) {
78 | try {
79 | const rules = RuleUtils.parse(resource);
80 | result = RuleUtils.produce(rules, 'Loon');
81 | } catch (e) {
82 | console.log(e.message ?? e);
83 | }
84 | }
85 | if ((!result || /^\s*$/.test(result)) && resourceUrl) {
86 | console.log(`解析器: 尝试从 ${resourceUrl} 获取规则`);
87 | try {
88 | let raw = await download(resourceUrl, arg?.ua, arg?.timeout);
89 | let rules = RuleUtils.parse(raw);
90 | result = RuleUtils.produce(rules, 'Loon');
91 | } catch (e) {
92 | console.log(e.message ?? e);
93 | }
94 | }
95 | }
96 | })()
97 | .catch(async (e) => {
98 | console.log('解析器: 出现错误');
99 | console.log(e.message ?? e);
100 | })
101 | .finally(() => {
102 | $done(result || '');
103 | });
104 |
--------------------------------------------------------------------------------
/backend/src/products/sub-store-0.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 路由拆分 - 本文件只包含不涉及到解析器的 RESTFul API
3 | */
4 |
5 | import { version } from '../../package.json';
6 | console.log(
7 | `
8 | ┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅
9 | Sub-Store -- v${version}
10 | ┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅
11 | `,
12 | );
13 |
14 | import migrate from '@/utils/migration';
15 | import express from '@/vendor/express';
16 | import $ from '@/core/app';
17 | import registerCollectionRoutes from '@/restful/collections';
18 | import registerSubscriptionRoutes from '@/restful/subscriptions';
19 | import registerArtifactRoutes from '@/restful/artifacts';
20 | import registerSettingRoutes from '@/restful/settings';
21 | import registerMiscRoutes from '@/restful/miscs';
22 | import registerSortRoutes from '@/restful/sort';
23 | import registerFileRoutes from '@/restful/file';
24 | import registerTokenRoutes from '@/restful/token';
25 | import registerModuleRoutes from '@/restful/module';
26 |
27 | migrate();
28 | serve();
29 |
30 | function serve() {
31 | const $app = express({ substore: $ });
32 |
33 | // register routes
34 | registerCollectionRoutes($app);
35 | registerSubscriptionRoutes($app);
36 | registerTokenRoutes($app);
37 | registerFileRoutes($app);
38 | registerModuleRoutes($app);
39 | registerArtifactRoutes($app);
40 | registerSettingRoutes($app);
41 | registerSortRoutes($app);
42 | registerMiscRoutes($app);
43 |
44 | $app.start();
45 | }
46 |
--------------------------------------------------------------------------------
/backend/src/products/sub-store-1.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 路由拆分 - 本文件仅包含使用到解析器的 RESTFul API
3 | */
4 |
5 | import { version } from '../../package.json';
6 | import migrate from '@/utils/migration';
7 | import express from '@/vendor/express';
8 | import $ from '@/core/app';
9 | import registerDownloadRoutes from '@/restful/download';
10 | import registerPreviewRoutes from '@/restful/preview';
11 | import registerSyncRoutes from '@/restful/sync';
12 | import registerNodeInfoRoutes from '@/restful/node-info';
13 |
14 | console.log(
15 | `
16 | ┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅
17 | Sub-Store -- v${version}
18 | ┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅
19 | `,
20 | );
21 |
22 | migrate();
23 | serve();
24 |
25 | function serve() {
26 | const $app = express({ substore: $ });
27 |
28 | // register routes
29 | registerDownloadRoutes($app);
30 | registerPreviewRoutes($app);
31 | registerSyncRoutes($app);
32 | registerNodeInfoRoutes($app);
33 |
34 | $app.options('/', (req, res) => {
35 | res.status(200).end();
36 | });
37 |
38 | $app.start();
39 | }
40 |
--------------------------------------------------------------------------------
/backend/src/restful/collections.js:
--------------------------------------------------------------------------------
1 | import { deleteByName, findByName, updateByName } from '@/utils/database';
2 | import { COLLECTIONS_KEY, ARTIFACTS_KEY, FILES_KEY } from '@/constants';
3 | import { failed, success } from '@/restful/response';
4 | import $ from '@/core/app';
5 | import { RequestInvalidError, ResourceNotFoundError } from '@/restful/errors';
6 | import { formatDateTime } from '@/utils';
7 |
8 | export default function register($app) {
9 | if (!$.read(COLLECTIONS_KEY)) $.write({}, COLLECTIONS_KEY);
10 |
11 | $app.route('/api/collection/:name')
12 | .get(getCollection)
13 | .patch(updateCollection)
14 | .delete(deleteCollection);
15 |
16 | $app.route('/api/collections')
17 | .get(getAllCollections)
18 | .post(createCollection)
19 | .put(replaceCollection);
20 | }
21 |
22 | // collection API
23 | function createCollection(req, res) {
24 | const collection = req.body;
25 | $.info(`正在创建组合订阅:${collection.name}`);
26 | if (/\//.test(collection.name)) {
27 | failed(
28 | res,
29 | new RequestInvalidError(
30 | 'INVALID_NAME',
31 | `Collection ${collection.name} is invalid`,
32 | ),
33 | );
34 | return;
35 | }
36 | const allCols = $.read(COLLECTIONS_KEY);
37 | if (findByName(allCols, collection.name)) {
38 | failed(
39 | res,
40 | new RequestInvalidError(
41 | 'DUPLICATE_KEY',
42 | `Collection ${collection.name} already exists.`,
43 | ),
44 | );
45 | return;
46 | }
47 | allCols.push(collection);
48 | $.write(allCols, COLLECTIONS_KEY);
49 | success(res, collection, 201);
50 | }
51 |
52 | function getCollection(req, res) {
53 | let { name } = req.params;
54 | let { raw } = req.query;
55 | name = decodeURIComponent(name);
56 | const allCols = $.read(COLLECTIONS_KEY);
57 | const collection = findByName(allCols, name);
58 | if (collection) {
59 | if (raw) {
60 | res.set('content-type', 'application/json')
61 | .set(
62 | 'content-disposition',
63 | `attachment; filename="${encodeURIComponent(
64 | `sub-store_collection_${name}_${formatDateTime(
65 | new Date(),
66 | )}.json`,
67 | )}"`,
68 | )
69 | .send(JSON.stringify(collection));
70 | } else {
71 | success(res, collection);
72 | }
73 | } else {
74 | failed(
75 | res,
76 | new ResourceNotFoundError(
77 | `SUBSCRIPTION_NOT_FOUND`,
78 | `Collection ${name} does not exist`,
79 | 404,
80 | ),
81 | );
82 | }
83 | }
84 |
85 | function updateCollection(req, res) {
86 | let { name } = req.params;
87 | name = decodeURIComponent(name);
88 | let collection = req.body;
89 | const allCols = $.read(COLLECTIONS_KEY);
90 | const oldCol = findByName(allCols, name);
91 | if (oldCol) {
92 | const newCol = {
93 | ...oldCol,
94 | ...collection,
95 | };
96 | $.info(`正在更新组合订阅:${name}...`);
97 |
98 | if (name !== newCol.name) {
99 | // update all artifacts referring this collection
100 | const allArtifacts = $.read(ARTIFACTS_KEY) || [];
101 | for (const artifact of allArtifacts) {
102 | if (
103 | artifact.type === 'collection' &&
104 | artifact.source === oldCol.name
105 | ) {
106 | artifact.source = newCol.name;
107 | }
108 | }
109 | // update all files referring this collection
110 | const allFiles = $.read(FILES_KEY) || [];
111 | for (const file of allFiles) {
112 | if (
113 | file.sourceType === 'collection' &&
114 | file.sourceName === oldCol.name
115 | ) {
116 | file.sourceName = newCol.name;
117 | }
118 | }
119 | $.write(allArtifacts, ARTIFACTS_KEY);
120 | $.write(allFiles, FILES_KEY);
121 | }
122 |
123 | updateByName(allCols, name, newCol);
124 | $.write(allCols, COLLECTIONS_KEY);
125 | success(res, newCol);
126 | } else {
127 | failed(
128 | res,
129 | new ResourceNotFoundError(
130 | 'RESOURCE_NOT_FOUND',
131 | `Collection ${name} does not exist!`,
132 | ),
133 | 404,
134 | );
135 | }
136 | }
137 |
138 | function deleteCollection(req, res) {
139 | let { name } = req.params;
140 | name = decodeURIComponent(name);
141 | $.info(`正在删除组合订阅:${name}`);
142 | let allCols = $.read(COLLECTIONS_KEY);
143 | deleteByName(allCols, name);
144 | $.write(allCols, COLLECTIONS_KEY);
145 | success(res);
146 | }
147 |
148 | function getAllCollections(req, res) {
149 | const allCols = $.read(COLLECTIONS_KEY);
150 | success(res, allCols);
151 | }
152 |
153 | function replaceCollection(req, res) {
154 | const allCols = req.body;
155 | $.write(allCols, COLLECTIONS_KEY);
156 | success(res);
157 | }
158 |
--------------------------------------------------------------------------------
/backend/src/restful/errors/index.js:
--------------------------------------------------------------------------------
1 | class BaseError {
2 | constructor(code, message, details) {
3 | this.code = code;
4 | this.message = message;
5 | this.details = details;
6 | }
7 | }
8 |
9 | export class InternalServerError extends BaseError {
10 | constructor(code, message, details) {
11 | super(code, message, details);
12 | this.type = 'InternalServerError';
13 | }
14 | }
15 |
16 | export class RequestInvalidError extends BaseError {
17 | constructor(code, message, details) {
18 | super(code, message, details);
19 | this.type = 'RequestInvalidError';
20 | }
21 | }
22 |
23 | export class ResourceNotFoundError extends BaseError {
24 | constructor(code, message, details) {
25 | super(code, message, details);
26 | this.type = 'ResourceNotFoundError';
27 | }
28 | }
29 |
30 | export class NetworkError extends BaseError {
31 | constructor(code, message, details) {
32 | super(code, message, details);
33 | this.type = 'NetworkError';
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/backend/src/restful/miscs.js:
--------------------------------------------------------------------------------
1 | import $ from '@/core/app';
2 | import { ENV } from '@/vendor/open-api';
3 | import { failed, success } from '@/restful/response';
4 | import { updateArtifactStore, updateAvatar } from '@/restful/settings';
5 | import resourceCache from '@/utils/resource-cache';
6 | import scriptResourceCache from '@/utils/script-resource-cache';
7 | import headersResourceCache from '@/utils/headers-resource-cache';
8 | import {
9 | GIST_BACKUP_FILE_NAME,
10 | GIST_BACKUP_KEY,
11 | SETTINGS_KEY,
12 | } from '@/constants';
13 | import { InternalServerError, RequestInvalidError } from '@/restful/errors';
14 | import Gist from '@/utils/gist';
15 | import migrate from '@/utils/migration';
16 | import env from '@/utils/env';
17 | import { formatDateTime } from '@/utils';
18 |
19 | export default function register($app) {
20 | // utils
21 | $app.get('/api/utils/env', getEnv); // get runtime environment
22 | $app.get('/api/utils/backup', gistBackup); // gist backup actions
23 | $app.get('/api/utils/refresh', refresh);
24 |
25 | // Storage management
26 | $app.route('/api/storage')
27 | .get((req, res) => {
28 | res.set('content-type', 'application/json')
29 | .set(
30 | 'content-disposition',
31 | `attachment; filename="${encodeURIComponent(
32 | `sub-store_data_${formatDateTime(new Date())}.json`,
33 | )}"`,
34 | )
35 | .send(
36 | $.env.isNode
37 | ? JSON.stringify($.cache)
38 | : $.read('#sub-store'),
39 | );
40 | })
41 | .post((req, res) => {
42 | const { content } = req.body;
43 | $.write(content, '#sub-store');
44 | if ($.env.isNode) {
45 | $.cache = JSON.parse(content);
46 | $.persistCache();
47 | }
48 | migrate();
49 | success(res);
50 | });
51 |
52 | if (ENV().isNode) {
53 | $app.get('/', getEnv);
54 | } else {
55 | // Redirect sub.store to vercel webpage
56 | $app.get('/', async (req, res) => {
57 | // 302 redirect
58 | res.set('location', 'https://sub-store.vercel.app/')
59 | .status(302)
60 | .end();
61 | });
62 | }
63 |
64 | // handle preflight request for QX
65 | if (ENV().isQX) {
66 | $app.options('/', async (req, res) => {
67 | res.status(200).end();
68 | });
69 | }
70 |
71 | $app.all('/', (_, res) => {
72 | res.send('Hello from sub-store, made with ❤️ by Peng-YM');
73 | });
74 | }
75 |
76 | function getEnv(req, res) {
77 | if (req.query.share) {
78 | env.feature.share = true;
79 | }
80 | res.set('Content-Type', 'application/json;charset=UTF-8').send(
81 | JSON.stringify(
82 | {
83 | status: 'success',
84 | data: {
85 | guide: '⚠️⚠️⚠️ 您当前看到的是后端的响应. 若想配合前端使用, 可访问官方前端 https://sub-store.vercel.app 后自行配置后端地址, 或一键配置后端 https://sub-store.vercel.app?api=https://a.com/xxx (假设 https://a.com 是你后端的域名, /xxx 是自定义路径). 需注意 HTTPS 前端无法请求非本地的 HTTP 后端(部分浏览器上也无法访问本地 HTTP 后端). 请配置反代或在局域网自建 HTTP 前端. 如果还有问题, 可查看此排查说明: https://t.me/zhetengsha/1068',
86 | ...env,
87 | },
88 | },
89 | null,
90 | 2,
91 | ),
92 | );
93 | }
94 |
95 | async function refresh(_, res) {
96 | // 1. get GitHub avatar and artifact store
97 | await updateAvatar();
98 | await updateArtifactStore();
99 |
100 | // 2. clear resource cache
101 | resourceCache.revokeAll();
102 | scriptResourceCache.revokeAll();
103 | headersResourceCache.revokeAll();
104 | success(res);
105 | }
106 |
107 | async function gistBackupAction(action) {
108 | // read token
109 | const { gistToken, syncPlatform } = $.read(SETTINGS_KEY);
110 | if (!gistToken) throw new Error('GitHub Token is required for backup!');
111 |
112 | const gist = new Gist({
113 | token: gistToken,
114 | key: GIST_BACKUP_KEY,
115 | syncPlatform,
116 | });
117 | let content;
118 | const settings = $.read(SETTINGS_KEY);
119 | const updated = settings.syncTime;
120 | switch (action) {
121 | case 'upload':
122 | try {
123 | content = $.read('#sub-store');
124 | if ($.env.isNode) content = JSON.stringify($.cache, null, ` `);
125 | $.info(`下载备份, 与本地内容对比...`);
126 | const onlineContent = await gist.download(
127 | GIST_BACKUP_FILE_NAME,
128 | );
129 | if (onlineContent === content) {
130 | $.info(`内容一致, 无需上传备份`);
131 | return;
132 | }
133 | } catch (error) {
134 | $.error(`${error.message ?? error}`);
135 | }
136 |
137 | // update syncTime
138 | settings.syncTime = new Date().getTime();
139 | $.write(settings, SETTINGS_KEY);
140 | content = $.read('#sub-store');
141 | if ($.env.isNode) content = JSON.stringify($.cache, null, ` `);
142 | $.info(`上传备份中...`);
143 | try {
144 | await gist.upload({
145 | [GIST_BACKUP_FILE_NAME]: { content },
146 | });
147 | $.info(`上传备份完成`);
148 | } catch (err) {
149 | // restore syncTime if upload failed
150 | settings.syncTime = updated;
151 | $.write(settings, SETTINGS_KEY);
152 | throw err;
153 | }
154 | break;
155 | case 'download':
156 | $.info(`还原备份中...`);
157 | content = await gist.download(GIST_BACKUP_FILE_NAME);
158 | try {
159 | if (Object.keys(JSON.parse(content).settings).length === 0) {
160 | throw new Error('备份文件应该至少包含 settings 字段');
161 | }
162 | } catch (err) {
163 | $.error(
164 | `Gist 备份文件校验失败, 无法还原\nReason: ${
165 | err.message ?? err
166 | }`,
167 | );
168 | throw new Error('Gist 备份文件校验失败, 无法还原');
169 | }
170 | // restore settings
171 | $.write(content, '#sub-store');
172 | if ($.env.isNode) {
173 | content = JSON.parse(content);
174 | $.cache = content;
175 | $.persistCache();
176 | }
177 | $.info(`perform migration after restoring from gist...`);
178 | migrate();
179 | $.info(`migration completed`);
180 | $.info(`还原备份完成`);
181 | break;
182 | }
183 | }
184 | async function gistBackup(req, res) {
185 | const { action } = req.query;
186 | // read token
187 | const { gistToken } = $.read(SETTINGS_KEY);
188 | if (!gistToken) {
189 | failed(
190 | res,
191 | new RequestInvalidError(
192 | 'GIST_TOKEN_NOT_FOUND',
193 | `GitHub Token is required for backup!`,
194 | ),
195 | );
196 | } else {
197 | try {
198 | await gistBackupAction(action);
199 | success(res);
200 | } catch (err) {
201 | $.error(
202 | `Failed to ${action} gist data.\nReason: ${err.message ?? err}`,
203 | );
204 | failed(
205 | res,
206 | new InternalServerError(
207 | 'BACKUP_FAILED',
208 | `Failed to ${action} gist data!`,
209 | `Reason: ${err.message ?? err}`,
210 | ),
211 | );
212 | }
213 | }
214 | }
215 |
216 | export { gistBackupAction };
217 |
--------------------------------------------------------------------------------
/backend/src/restful/module.js:
--------------------------------------------------------------------------------
1 | import { deleteByName, findByName, updateByName } from '@/utils/database';
2 | import { MODULES_KEY } from '@/constants';
3 | import { failed, success } from '@/restful/response';
4 | import $ from '@/core/app';
5 | import { RequestInvalidError, ResourceNotFoundError } from '@/restful/errors';
6 | import { hex_md5 } from '@/vendor/md5';
7 |
8 | export default function register($app) {
9 | if (!$.read(MODULES_KEY)) $.write([], MODULES_KEY);
10 |
11 | $app.route('/api/module/:name')
12 | .get(getModule)
13 | .patch(updateModule)
14 | .delete(deleteModule);
15 |
16 | $app.route('/api/modules')
17 | .get(getAllModules)
18 | .post(createModule)
19 | .put(replaceModule);
20 | }
21 |
22 | // module API
23 | function createModule(req, res) {
24 | const module = req.body;
25 | module.name = `${module.name ?? hex_md5(JSON.stringify(module))}`;
26 | $.info(`正在创建模块:${module.name}`);
27 | const allModules = $.read(MODULES_KEY);
28 | if (findByName(allModules, module.name)) {
29 | return failed(
30 | res,
31 | new RequestInvalidError(
32 | 'DUPLICATE_KEY',
33 | req.body.name
34 | ? `已存在 name 为 ${module.name} 的模块`
35 | : `已存在相同的模块 请勿重复添加`,
36 | ),
37 | );
38 | }
39 | allModules.push(module);
40 | $.write(allModules, MODULES_KEY);
41 | success(res, module, 201);
42 | }
43 |
44 | function getModule(req, res) {
45 | let { name } = req.params;
46 | name = decodeURIComponent(name);
47 | const allModules = $.read(MODULES_KEY);
48 | const module = findByName(allModules, name);
49 | if (module) {
50 | res.set('Content-Type', 'text/plain; charset=utf-8').send(
51 | module.content,
52 | );
53 | } else {
54 | failed(
55 | res,
56 | new ResourceNotFoundError(
57 | `MODULE_NOT_FOUND`,
58 | `Module ${name} does not exist`,
59 | 404,
60 | ),
61 | );
62 | }
63 | }
64 |
65 | function updateModule(req, res) {
66 | let { name } = req.params;
67 | name = decodeURIComponent(name);
68 | let module = req.body;
69 | const allModules = $.read(MODULES_KEY);
70 | const oldModule = findByName(allModules, name);
71 | if (oldModule) {
72 | const newModule = {
73 | ...oldModule,
74 | ...module,
75 | };
76 | $.info(`正在更新模块:${name}...`);
77 |
78 | updateByName(allModules, name, newModule);
79 | $.write(allModules, MODULES_KEY);
80 | success(res, newModule);
81 | } else {
82 | failed(
83 | res,
84 | new ResourceNotFoundError(
85 | 'RESOURCE_NOT_FOUND',
86 | `Module ${name} does not exist!`,
87 | ),
88 | 404,
89 | );
90 | }
91 | }
92 |
93 | function deleteModule(req, res) {
94 | let { name } = req.params;
95 | name = decodeURIComponent(name);
96 | $.info(`正在删除模块:${name}`);
97 | let allModules = $.read(MODULES_KEY);
98 | deleteByName(allModules, name);
99 | $.write(allModules, MODULES_KEY);
100 | success(res);
101 | }
102 |
103 | function getAllModules(req, res) {
104 | const allModules = $.read(MODULES_KEY);
105 | success(
106 | res,
107 | // eslint-disable-next-line no-unused-vars
108 | allModules.map(({ content, ...rest }) => rest),
109 | );
110 | }
111 |
112 | function replaceModule(req, res) {
113 | const allModules = req.body;
114 | $.write(allModules, MODULES_KEY);
115 | success(res);
116 | }
117 |
--------------------------------------------------------------------------------
/backend/src/restful/node-info.js:
--------------------------------------------------------------------------------
1 | import producer from '@/core/proxy-utils/producers';
2 | import { HTTP } from '@/vendor/open-api';
3 | import { failed, success } from '@/restful/response';
4 | import { NetworkError } from '@/restful/errors';
5 |
6 | export default function register($app) {
7 | $app.post('/api/utils/node-info', getNodeInfo);
8 | }
9 |
10 | async function getNodeInfo(req, res) {
11 | const proxy = req.body;
12 | const lang = req.query.lang || 'zh-CN';
13 | let shareUrl;
14 | try {
15 | shareUrl = producer.URI.produce(proxy);
16 | } catch (err) {
17 | // do nothing
18 | }
19 |
20 | try {
21 | const $http = HTTP();
22 | const info = await $http
23 | .get({
24 | url: `http://ip-api.com/json/${encodeURIComponent(
25 | `${proxy.server}`
26 | .trim()
27 | .replace(/^\[/, '')
28 | .replace(/\]$/, ''),
29 | )}?lang=${lang}`,
30 | headers: {
31 | 'User-Agent':
32 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 12_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Safari/605.1.15',
33 | },
34 | })
35 | .then((resp) => {
36 | const data = JSON.parse(resp.body);
37 | if (data.status !== 'success') {
38 | throw new Error(data.message);
39 | }
40 |
41 | // remove unnecessary fields
42 | delete data.status;
43 | return data;
44 | });
45 | success(res, {
46 | shareUrl,
47 | info,
48 | });
49 | } catch (err) {
50 | failed(
51 | res,
52 | new NetworkError(
53 | 'FAILED_TO_GET_NODE_INFO',
54 | `Failed to get node info`,
55 | `Reason: ${err}`,
56 | ),
57 | );
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/backend/src/restful/parser.js:
--------------------------------------------------------------------------------
1 | import { success, failed } from '@/restful/response';
2 | import { ProxyUtils } from '@/core/proxy-utils';
3 | import { RuleUtils } from '@/core/rule-utils';
4 |
5 | export default function register($app) {
6 | $app.route('/api/proxy/parse').post(proxy_parser);
7 | $app.route('/api/rule/parse').post(rule_parser);
8 | }
9 |
10 | /***
11 | * 感谢 izhangxm 的 PR!
12 | * 目前没有节点操作, 没有支持完整参数, 以后再完善一下
13 | */
14 |
15 | /***
16 | * 代理服务器协议转换接口。
17 | * 请求方法为POST,数据为json。需要提供data和client字段。
18 | * data: string, 协议数据,每行一个或者是clash
19 | * client: string, 目标平台名称,见backend/src/core/proxy-utils/producers/index.js
20 | *
21 | */
22 | function proxy_parser(req, res) {
23 | const { data, client, content, platform } = req.body;
24 | var result = {};
25 | try {
26 | var proxies = ProxyUtils.parse(data ?? content);
27 | var par_res = ProxyUtils.produce(proxies, client ?? platform);
28 | result['par_res'] = par_res;
29 | } catch (err) {
30 | failed(res, err);
31 | return;
32 | }
33 | success(res, result);
34 | }
35 | /**
36 | * 规则转换接口。
37 | * 请求方法为POST,数据为json。需要提供data和client字段。
38 | * data: string, 多行规则字符串
39 | * client: string, 目标平台名称,具体见backend/src/core/rule-utils/producers.js
40 | */
41 | function rule_parser(req, res) {
42 | const { data, client, content, platform } = req.body;
43 | var result = {};
44 | try {
45 | const rules = RuleUtils.parse(data ?? content);
46 | var par_res = RuleUtils.produce(rules, client ?? platform);
47 | result['par_res'] = par_res;
48 | } catch (err) {
49 | failed(res, err);
50 | return;
51 | }
52 |
53 | success(res, result);
54 | }
55 |
--------------------------------------------------------------------------------
/backend/src/restful/response.js:
--------------------------------------------------------------------------------
1 | export function success(resp, data, statusCode) {
2 | resp.status(statusCode || 200).json({
3 | status: 'success',
4 | data,
5 | });
6 | }
7 |
8 | export function failed(resp, error, statusCode) {
9 | resp.status(statusCode || 500).json({
10 | status: 'failed',
11 | error: {
12 | code: error.code,
13 | type: error.type,
14 | message: error.message,
15 | details: error.details,
16 | },
17 | });
18 | }
19 |
--------------------------------------------------------------------------------
/backend/src/restful/settings.js:
--------------------------------------------------------------------------------
1 | import { SETTINGS_KEY, ARTIFACT_REPOSITORY_KEY } from '@/constants';
2 | import { success, failed } from './response';
3 | import { InternalServerError } from '@/restful/errors';
4 | import $ from '@/core/app';
5 | import Gist from '@/utils/gist';
6 |
7 | export default function register($app) {
8 | const settings = $.read(SETTINGS_KEY);
9 | if (!settings) $.write({}, SETTINGS_KEY);
10 | $app.route('/api/settings').get(getSettings).patch(updateSettings);
11 | }
12 |
13 | async function getSettings(req, res) {
14 | try {
15 | let settings = $.read(SETTINGS_KEY);
16 | if (!settings) {
17 | settings = {};
18 | $.write(settings, SETTINGS_KEY);
19 | }
20 |
21 | if (!settings.avatarUrl) await updateAvatar();
22 | if (!settings.artifactStore) await updateArtifactStore();
23 |
24 | success(res, settings);
25 | } catch (e) {
26 | $.error(`Failed to get settings: ${e.message ?? e}`);
27 | failed(
28 | res,
29 | new InternalServerError(
30 | `FAILED_TO_GET_SETTINGS`,
31 | `Failed to get settings`,
32 | `Reason: ${e.message ?? e}`,
33 | ),
34 | );
35 | }
36 | }
37 |
38 | async function updateSettings(req, res) {
39 | try {
40 | const settings = $.read(SETTINGS_KEY);
41 | const newSettings = {
42 | ...settings,
43 | ...req.body,
44 | };
45 | $.write(newSettings, SETTINGS_KEY);
46 | await updateAvatar();
47 | await updateArtifactStore();
48 | success(res, newSettings);
49 | } catch (e) {
50 | $.error(`Failed to update settings: ${e.message ?? e}`);
51 | failed(
52 | res,
53 | new InternalServerError(
54 | `FAILED_TO_UPDATE_SETTINGS`,
55 | `Failed to update settings`,
56 | `Reason: ${e.message ?? e}`,
57 | ),
58 | );
59 | }
60 | }
61 |
62 | export async function updateAvatar() {
63 | const settings = $.read(SETTINGS_KEY);
64 | const { githubUser: username, syncPlatform } = settings;
65 | if (username) {
66 | if (syncPlatform === 'gitlab') {
67 | try {
68 | const data = await $.http
69 | .get({
70 | url: `https://gitlab.com/api/v4/users?username=${encodeURIComponent(
71 | username,
72 | )}`,
73 | headers: {
74 | 'User-Agent':
75 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.141 Safari/537.36',
76 | },
77 | })
78 | .then((resp) => JSON.parse(resp.body));
79 | settings.avatarUrl = data[0]['avatar_url'].replace(
80 | /(\?|&)s=\d+(&|$)/,
81 | '$1s=160$2',
82 | );
83 | $.write(settings, SETTINGS_KEY);
84 | } catch (err) {
85 | $.error(
86 | `Failed to fetch GitLab avatar for User: ${username}. Reason: ${
87 | err.message ?? err
88 | }`,
89 | );
90 | }
91 | } else {
92 | try {
93 | const data = await $.http
94 | .get({
95 | url: `https://api.github.com/users/${encodeURIComponent(
96 | username,
97 | )}`,
98 | headers: {
99 | 'User-Agent':
100 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.141 Safari/537.36',
101 | },
102 | })
103 | .then((resp) => JSON.parse(resp.body));
104 | settings.avatarUrl = data['avatar_url'];
105 | $.write(settings, SETTINGS_KEY);
106 | } catch (err) {
107 | $.error(
108 | `Failed to fetch GitHub avatar for User: ${username}. Reason: ${
109 | err.message ?? err
110 | }`,
111 | );
112 | }
113 | }
114 | }
115 | }
116 |
117 | export async function updateArtifactStore() {
118 | $.log('Updating artifact store');
119 | const settings = $.read(SETTINGS_KEY);
120 | const { gistToken, syncPlatform } = settings;
121 | if (gistToken) {
122 | const manager = new Gist({
123 | token: gistToken,
124 | key: ARTIFACT_REPOSITORY_KEY,
125 | syncPlatform,
126 | });
127 |
128 | try {
129 | const gist = await manager.locate();
130 | const url = gist?.html_url ?? gist?.web_url;
131 | if (url) {
132 | $.log(`找到 Sub-Store Gist: ${url}`);
133 | // 只需要保证 token 是对的, 现在 username 错误只会导致头像错误
134 | settings.artifactStore = url;
135 | settings.artifactStoreStatus = 'VALID';
136 | } else {
137 | $.error(`找不到 Sub-Store Gist (${ARTIFACT_REPOSITORY_KEY})`);
138 | settings.artifactStoreStatus = 'NOT FOUND';
139 | }
140 | } catch (err) {
141 | $.error(
142 | `查找 Sub-Store Gist (${ARTIFACT_REPOSITORY_KEY}) 时发生错误: ${
143 | err.message ?? err
144 | }`,
145 | );
146 | settings.artifactStoreStatus = 'ERROR';
147 | }
148 | $.write(settings, SETTINGS_KEY);
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/backend/src/restful/sort.js:
--------------------------------------------------------------------------------
1 | import {
2 | ARTIFACTS_KEY,
3 | COLLECTIONS_KEY,
4 | SUBS_KEY,
5 | FILES_KEY,
6 | } from '@/constants';
7 | import $ from '@/core/app';
8 | import { success } from '@/restful/response';
9 |
10 | export default function register($app) {
11 | $app.post('/api/sort/subs', sortSubs);
12 | $app.post('/api/sort/collections', sortCollections);
13 | $app.post('/api/sort/artifacts', sortArtifacts);
14 | $app.post('/api/sort/files', sortFiles);
15 | }
16 |
17 | function sortSubs(req, res) {
18 | const orders = req.body;
19 | const allSubs = $.read(SUBS_KEY);
20 | allSubs.sort((a, b) => orders.indexOf(a.name) - orders.indexOf(b.name));
21 | $.write(allSubs, SUBS_KEY);
22 | success(res, allSubs);
23 | }
24 |
25 | function sortCollections(req, res) {
26 | const orders = req.body;
27 | const allCols = $.read(COLLECTIONS_KEY);
28 | allCols.sort((a, b) => orders.indexOf(a.name) - orders.indexOf(b.name));
29 | $.write(allCols, COLLECTIONS_KEY);
30 | success(res, allCols);
31 | }
32 |
33 | function sortArtifacts(req, res) {
34 | const orders = req.body;
35 | const allArtifacts = $.read(ARTIFACTS_KEY);
36 | allArtifacts.sort(
37 | (a, b) => orders.indexOf(a.name) - orders.indexOf(b.name),
38 | );
39 | $.write(allArtifacts, ARTIFACTS_KEY);
40 | success(res, allArtifacts);
41 | }
42 |
43 | function sortFiles(req, res) {
44 | const orders = req.body;
45 | const allFiles = $.read(FILES_KEY);
46 | allFiles.sort((a, b) => orders.indexOf(a.name) - orders.indexOf(b.name));
47 | $.write(allFiles, FILES_KEY);
48 | success(res, allFiles);
49 | }
50 |
--------------------------------------------------------------------------------
/backend/src/restful/token.js:
--------------------------------------------------------------------------------
1 | import { deleteByName } from '@/utils/database';
2 | import { ENV } from '@/vendor/open-api';
3 | import { TOKENS_KEY, SUBS_KEY, FILES_KEY, COLLECTIONS_KEY } from '@/constants';
4 | import { failed, success } from '@/restful/response';
5 | import $ from '@/core/app';
6 | import { RequestInvalidError, InternalServerError } from '@/restful/errors';
7 |
8 | export default function register($app) {
9 | if (!$.read(TOKENS_KEY)) $.write([], TOKENS_KEY);
10 |
11 | $app.post('/api/token', signToken);
12 |
13 | $app.route('/api/token/:token').delete(deleteToken);
14 |
15 | $app.route('/api/tokens').get(getAllTokens);
16 | }
17 |
18 | function deleteToken(req, res) {
19 | let { token } = req.params;
20 | token = decodeURIComponent(token);
21 | $.info(`正在删除:${token}`);
22 | let allTokens = $.read(TOKENS_KEY);
23 | deleteByName(allTokens, token, 'token');
24 | $.write(allTokens, TOKENS_KEY);
25 | success(res);
26 | }
27 |
28 | function getAllTokens(req, res) {
29 | const { type, name } = req.query;
30 | const allTokens = $.read(TOKENS_KEY) || [];
31 | success(
32 | res,
33 | type || name
34 | ? allTokens.filter(
35 | (item) =>
36 | (type ? item.type === type : true) &&
37 | (name ? item.name === name : true),
38 | )
39 | : allTokens,
40 | );
41 | }
42 |
43 | async function signToken(req, res) {
44 | if (!ENV().isNode) {
45 | return failed(
46 | res,
47 | new RequestInvalidError(
48 | 'INVALID_ENV',
49 | `This endpoint is only available in Node.js environment`,
50 | ),
51 | );
52 | }
53 | try {
54 | const { payload, options } = req.body;
55 | const ms = eval(`require("ms")`);
56 | let token = payload?.token;
57 | if (token != null) {
58 | if (typeof token !== 'string' || token.length < 1) {
59 | return failed(
60 | res,
61 | new RequestInvalidError(
62 | 'INVALID_CUSTOM_TOKEN',
63 | `Invalid custom token: ${token}`,
64 | ),
65 | );
66 | }
67 | const tokens = $.read(TOKENS_KEY) || [];
68 | if (tokens.find((t) => t.token === token)) {
69 | return failed(
70 | res,
71 | new RequestInvalidError(
72 | 'DUPLICATE_TOKEN',
73 | `Token ${token} already exists`,
74 | ),
75 | );
76 | }
77 | }
78 | const type = payload?.type;
79 | const name = payload?.name;
80 | if (!type || !name)
81 | return failed(
82 | res,
83 | new RequestInvalidError(
84 | 'INVALID_PAYLOAD',
85 | `payload type and name are required`,
86 | ),
87 | );
88 | if (type === 'col') {
89 | const collections = $.read(COLLECTIONS_KEY) || [];
90 | const collection = collections.find((c) => c.name === name);
91 | if (!collection)
92 | return failed(
93 | res,
94 | new RequestInvalidError(
95 | 'INVALID_COLLECTION',
96 | `collection ${name} not found`,
97 | ),
98 | );
99 | } else if (type === 'file') {
100 | const files = $.read(FILES_KEY) || [];
101 | const file = files.find((f) => f.name === name);
102 | if (!file)
103 | return failed(
104 | res,
105 | new RequestInvalidError(
106 | 'INVALID_FILE',
107 | `file ${name} not found`,
108 | ),
109 | );
110 | } else if (type === 'sub') {
111 | const subs = $.read(SUBS_KEY) || [];
112 | const sub = subs.find((s) => s.name === name);
113 | if (!sub)
114 | return failed(
115 | res,
116 | new RequestInvalidError(
117 | 'INVALID_SUB',
118 | `sub ${name} not found`,
119 | ),
120 | );
121 | } else {
122 | return failed(
123 | res,
124 | new RequestInvalidError(
125 | 'INVALID_TYPE',
126 | `type ${name} not supported`,
127 | ),
128 | );
129 | }
130 | let expiresIn = options?.expiresIn;
131 | if (options?.expiresIn != null) {
132 | expiresIn = ms(options.expiresIn);
133 | if (expiresIn == null || isNaN(expiresIn) || expiresIn <= 0) {
134 | return failed(
135 | res,
136 | new RequestInvalidError(
137 | 'INVALID_EXPIRES_IN',
138 | `Invalid expiresIn option: ${options.expiresIn}`,
139 | ),
140 | );
141 | }
142 | }
143 | // const secret = eval('process.env.SUB_STORE_FRONTEND_BACKEND_PATH');
144 | const nanoid = eval(`require("nanoid")`);
145 | const tokens = $.read(TOKENS_KEY) || [];
146 | // const now = Date.now();
147 | // for (const key in tokens) {
148 | // const token = tokens[key];
149 | // if (token.exp != null || token.exp < now) {
150 | // delete tokens[key];
151 | // }
152 | // }
153 | if (!token) {
154 | do {
155 | token = nanoid.customAlphabet(nanoid.urlAlphabet)();
156 | } while (tokens.find((t) => t.token === token));
157 | }
158 | tokens.push({
159 | ...payload,
160 | token,
161 | createdAt: Date.now(),
162 | expiresIn: expiresIn > 0 ? options?.expiresIn : undefined,
163 | exp: expiresIn > 0 ? Date.now() + expiresIn : undefined,
164 | });
165 |
166 | $.write(tokens, TOKENS_KEY);
167 | return success(res, {
168 | token,
169 | // secret,
170 | });
171 | } catch (e) {
172 | return failed(
173 | res,
174 | new InternalServerError(
175 | 'TOKEN_SIGN_FAILED',
176 | `Failed to sign token`,
177 | `Reason: ${e.message ?? e}`,
178 | ),
179 | );
180 | }
181 | }
182 |
--------------------------------------------------------------------------------
/backend/src/test/proxy-parsers/loon.spec.js:
--------------------------------------------------------------------------------
1 | import getLoonParser from '@/core/proxy-utils/parsers/peggy/loon';
2 | import { describe, it } from 'mocha';
3 | import testcases from './testcases';
4 | import { expect } from 'chai';
5 |
6 | const parser = getLoonParser();
7 |
8 | describe('Loon', function () {
9 | describe('shadowsocks', function () {
10 | it('test shadowsocks simple', function () {
11 | const { input, expected } = testcases.SS.SIMPLE;
12 | const proxy = parser.parse(input.Loon);
13 | expect(proxy).eql(expected);
14 | });
15 | it('test shadowsocks obfs + tls', function () {
16 | const { input, expected } = testcases.SS.OBFS_TLS;
17 | const proxy = parser.parse(input.Loon);
18 | expect(proxy).eql(expected);
19 | });
20 | it('test shadowsocks obfs + http', function () {
21 | const { input, expected } = testcases.SS.OBFS_HTTP;
22 | const proxy = parser.parse(input.Loon);
23 | expect(proxy).eql(expected);
24 | });
25 | });
26 |
27 | describe('shadowsocksr', function () {
28 | it('test shadowsocksr simple', function () {
29 | const { input, expected } = testcases.SSR.SIMPLE;
30 | const proxy = parser.parse(input.Loon);
31 | expect(proxy).eql(expected);
32 | });
33 | });
34 |
35 | describe('trojan', function () {
36 | it('test trojan simple', function () {
37 | const { input, expected } = testcases.TROJAN.SIMPLE;
38 | const proxy = parser.parse(input.Loon);
39 | expect(proxy).eql(expected);
40 | });
41 |
42 | it('test trojan + ws', function () {
43 | const { input, expected } = testcases.TROJAN.WS;
44 | const proxy = parser.parse(input.Loon);
45 | expect(proxy).eql(expected);
46 | });
47 |
48 | it('test trojan + wss', function () {
49 | const { input, expected } = testcases.TROJAN.WSS;
50 | const proxy = parser.parse(input.Loon);
51 | expect(proxy).eql(expected);
52 | });
53 | });
54 |
55 | describe('vmess', function () {
56 | it('test vmess simple', function () {
57 | const { input, expected } = testcases.VMESS.SIMPLE;
58 | const proxy = parser.parse(input.Loon);
59 | expect(proxy).eql(expected.Loon);
60 | });
61 |
62 | it('test vmess + aead', function () {
63 | const { input, expected } = testcases.VMESS.AEAD;
64 | const proxy = parser.parse(input.Loon);
65 | expect(proxy).eql(expected.Loon);
66 | });
67 |
68 | it('test vmess + ws', function () {
69 | const { input, expected } = testcases.VMESS.WS;
70 | const proxy = parser.parse(input.Loon);
71 | expect(proxy).eql(expected.Loon);
72 | });
73 |
74 | it('test vmess + wss', function () {
75 | const { input, expected } = testcases.VMESS.WSS;
76 | const proxy = parser.parse(input.Loon);
77 | expect(proxy).eql(expected.Loon);
78 | });
79 |
80 | it('test vmess + http', function () {
81 | const { input, expected } = testcases.VMESS.HTTP;
82 | const proxy = parser.parse(input.Loon);
83 | expect(proxy).eql(expected.Loon);
84 | });
85 |
86 | it('test vmess + http + tls', function () {
87 | const { input, expected } = testcases.VMESS.HTTP_TLS;
88 | const proxy = parser.parse(input.Loon);
89 | expect(proxy).eql(expected.Loon);
90 | });
91 | });
92 |
93 | describe('vless', function () {
94 | it('test vless simple', function () {
95 | const { input, expected } = testcases.VLESS.SIMPLE;
96 | const proxy = parser.parse(input.Loon);
97 | expect(proxy).eql(expected.Loon);
98 | });
99 |
100 | it('test vless + ws', function () {
101 | const { input, expected } = testcases.VLESS.WS;
102 | const proxy = parser.parse(input.Loon);
103 | expect(proxy).eql(expected.Loon);
104 | });
105 |
106 | it('test vless + wss', function () {
107 | const { input, expected } = testcases.VLESS.WSS;
108 | const proxy = parser.parse(input.Loon);
109 | expect(proxy).eql(expected.Loon);
110 | });
111 |
112 | it('test vless + http', function () {
113 | const { input, expected } = testcases.VLESS.HTTP;
114 | const proxy = parser.parse(input.Loon);
115 | expect(proxy).eql(expected.Loon);
116 | });
117 |
118 | it('test vless + http + tls', function () {
119 | const { input, expected } = testcases.VLESS.HTTP_TLS;
120 | const proxy = parser.parse(input.Loon);
121 | expect(proxy).eql(expected.Loon);
122 | });
123 | });
124 |
125 | describe('http(s)', function () {
126 | it('test http simple', function () {
127 | const { input, expected } = testcases.HTTP.SIMPLE;
128 | const proxy = parser.parse(input.Loon);
129 | expect(proxy).eql(expected);
130 | });
131 |
132 | it('test http with authentication', function () {
133 | const { input, expected } = testcases.HTTP.AUTH;
134 | const proxy = parser.parse(input.Loon);
135 | expect(proxy).eql(expected);
136 | });
137 |
138 | it('test https', function () {
139 | const { input, expected } = testcases.HTTP.TLS;
140 | const proxy = parser.parse(input.Loon);
141 | expect(proxy).eql(expected);
142 | });
143 | });
144 | });
145 |
--------------------------------------------------------------------------------
/backend/src/test/proxy-parsers/qx.spec.js:
--------------------------------------------------------------------------------
1 | import getQXParser from '@/core/proxy-utils/parsers/peggy/qx';
2 | import { describe, it } from 'mocha';
3 | import testcases from './testcases';
4 | import { expect } from 'chai';
5 |
6 | const parser = getQXParser();
7 |
8 | describe('QX', function () {
9 | describe('shadowsocks', function () {
10 | it('test shadowsocks simple', function () {
11 | const { input, expected } = testcases.SS.SIMPLE;
12 | const proxy = parser.parse(input.QX);
13 | expect(proxy).eql(expected);
14 | });
15 | it('test shadowsocks obfs + tls', function () {
16 | const { input, expected } = testcases.SS.OBFS_TLS;
17 | const proxy = parser.parse(input.QX);
18 | expect(proxy).eql(expected);
19 | });
20 | it('test shadowsocks obfs + http', function () {
21 | const { input, expected } = testcases.SS.OBFS_HTTP;
22 | const proxy = parser.parse(input.QX);
23 | expect(proxy).eql(expected);
24 | });
25 | it('test shadowsocks v2ray-plugin + ws', function () {
26 | const { input, expected } = testcases.SS.V2RAY_PLUGIN_WS;
27 | const proxy = parser.parse(input.QX);
28 | expect(proxy).eql(expected);
29 | });
30 | it('test shadowsocks v2ray-plugin + wss', function () {
31 | const { input, expected } = testcases.SS.V2RAY_PLUGIN_WSS;
32 | const proxy = parser.parse(input.QX);
33 | expect(proxy).eql(expected);
34 | });
35 | });
36 |
37 | describe('shadowsocksr', function () {
38 | it('test shadowsocksr simple', function () {
39 | const { input, expected } = testcases.SSR.SIMPLE;
40 | const proxy = parser.parse(input.QX);
41 | expect(proxy).eql(expected);
42 | });
43 | });
44 |
45 | describe('trojan', function () {
46 | it('test trojan simple', function () {
47 | const { input, expected } = testcases.TROJAN.SIMPLE;
48 | const proxy = parser.parse(input.QX);
49 | expect(proxy).eql(expected);
50 | });
51 |
52 | it('test trojan + ws', function () {
53 | const { input, expected } = testcases.TROJAN.WS;
54 | const proxy = parser.parse(input.QX);
55 | expect(proxy).eql(expected);
56 | });
57 |
58 | it('test trojan + wss', function () {
59 | const { input, expected } = testcases.TROJAN.WSS;
60 | const proxy = parser.parse(input.QX);
61 | expect(proxy).eql(expected);
62 | });
63 |
64 | it('test trojan + tls fingerprint', function () {
65 | const { input, expected } = testcases.TROJAN.TLS_FINGERPRINT;
66 | const proxy = parser.parse(input.QX);
67 | expect(proxy).eql(expected);
68 | });
69 | });
70 |
71 | describe('vmess', function () {
72 | it('test vmess simple', function () {
73 | const { input, expected } = testcases.VMESS.SIMPLE;
74 | const proxy = parser.parse(input.QX);
75 | expect(proxy).eql(expected.QX);
76 | });
77 |
78 | it('test vmess aead', function () {
79 | const { input, expected } = testcases.VMESS.AEAD;
80 | const proxy = parser.parse(input.QX);
81 | expect(proxy).eql(expected.QX);
82 | });
83 |
84 | it('test vmess + ws', function () {
85 | const { input, expected } = testcases.VMESS.WS;
86 | const proxy = parser.parse(input.QX);
87 | expect(proxy).eql(expected.QX);
88 | });
89 |
90 | it('test vmess + wss', function () {
91 | const { input, expected } = testcases.VMESS.WSS;
92 | const proxy = parser.parse(input.QX);
93 | expect(proxy).eql(expected.QX);
94 | });
95 |
96 | it('test vmess + http', function () {
97 | const { input, expected } = testcases.VMESS.HTTP;
98 | const proxy = parser.parse(input.QX);
99 | expect(proxy).eql(expected.QX);
100 | });
101 | });
102 |
103 | describe('http', function () {
104 | it('test http simple', function () {
105 | const { input, expected } = testcases.HTTP.SIMPLE;
106 | const proxy = parser.parse(input.QX);
107 | expect(proxy).eql(expected);
108 | });
109 |
110 | it('test http with authentication', function () {
111 | const { input, expected } = testcases.HTTP.AUTH;
112 | const proxy = parser.parse(input.QX);
113 | expect(proxy).eql(expected);
114 | });
115 |
116 | it('test https', function () {
117 | const { input, expected } = testcases.HTTP.TLS;
118 | const proxy = parser.parse(input.QX);
119 | expect(proxy).eql(expected);
120 | });
121 | });
122 |
123 | describe('socks5', function () {
124 | it('test socks5 simple', function () {
125 | const { input, expected } = testcases.SOCKS5.SIMPLE;
126 | const proxy = parser.parse(input.QX);
127 | expect(proxy).eql(expected);
128 | });
129 |
130 | it('test socks5 with authentication', function () {
131 | const { input, expected } = testcases.SOCKS5.AUTH;
132 | const proxy = parser.parse(input.QX);
133 | expect(proxy).eql(expected);
134 | });
135 |
136 | it('test socks5 + tls', function () {
137 | const { input, expected } = testcases.SOCKS5.TLS;
138 | const proxy = parser.parse(input.QX);
139 | expect(proxy).eql(expected);
140 | });
141 | });
142 | });
143 |
--------------------------------------------------------------------------------
/backend/src/test/proxy-parsers/surge.spec.js:
--------------------------------------------------------------------------------
1 | import getSurgeParser from '@/core/proxy-utils/parsers/peggy/surge';
2 | import { describe, it } from 'mocha';
3 | import testcases from './testcases';
4 | import { expect } from 'chai';
5 |
6 | const parser = getSurgeParser();
7 |
8 | describe('Surge', function () {
9 | describe('shadowsocks', function () {
10 | it('test shadowsocks simple', function () {
11 | const { input, expected } = testcases.SS.SIMPLE;
12 | const proxy = parser.parse(input.Surge);
13 | expect(proxy).eql(expected);
14 | });
15 | it('test shadowsocks obfs + tls', function () {
16 | const { input, expected } = testcases.SS.OBFS_TLS;
17 | const proxy = parser.parse(input.Surge);
18 | expect(proxy).eql(expected);
19 | });
20 | it('test shadowsocks obfs + http', function () {
21 | const { input, expected } = testcases.SS.OBFS_HTTP;
22 | const proxy = parser.parse(input.Surge);
23 | expect(proxy).eql(expected);
24 | });
25 | });
26 |
27 | describe('trojan', function () {
28 | it('test trojan simple', function () {
29 | const { input, expected } = testcases.TROJAN.SIMPLE;
30 | const proxy = parser.parse(input.Surge);
31 | expect(proxy).eql(expected);
32 | });
33 |
34 | it('test trojan + ws', function () {
35 | const { input, expected } = testcases.TROJAN.WS;
36 | const proxy = parser.parse(input.Surge);
37 | expect(proxy).eql(expected);
38 | });
39 |
40 | it('test trojan + wss', function () {
41 | const { input, expected } = testcases.TROJAN.WSS;
42 | const proxy = parser.parse(input.Surge);
43 | expect(proxy).eql(expected);
44 | });
45 |
46 | it('test trojan + tls fingerprint', function () {
47 | const { input, expected } = testcases.TROJAN.TLS_FINGERPRINT;
48 | const proxy = parser.parse(input.Surge);
49 | expect(proxy).eql(expected);
50 | });
51 | });
52 |
53 | describe('vmess', function () {
54 | it('test vmess simple', function () {
55 | const { input, expected } = testcases.VMESS.SIMPLE;
56 | const proxy = parser.parse(input.Surge);
57 | expect(proxy).eql(expected.Surge);
58 | });
59 |
60 | it('test vmess aead', function () {
61 | const { input, expected } = testcases.VMESS.AEAD;
62 | const proxy = parser.parse(input.Surge);
63 | expect(proxy).eql(expected.Surge);
64 | });
65 |
66 | it('test vmess + ws', function () {
67 | const { input, expected } = testcases.VMESS.WS;
68 | const proxy = parser.parse(input.Surge);
69 | expect(proxy).eql(expected.Surge);
70 | });
71 |
72 | it('test vmess + wss', function () {
73 | const { input, expected } = testcases.VMESS.WSS;
74 | const proxy = parser.parse(input.Surge);
75 | expect(proxy).eql(expected.Surge);
76 | });
77 | });
78 |
79 | describe('http', function () {
80 | it('test http simple', function () {
81 | const { input, expected } = testcases.HTTP.SIMPLE;
82 | const proxy = parser.parse(input.Surge);
83 | expect(proxy).eql(expected);
84 | });
85 |
86 | it('test http with authentication', function () {
87 | const { input, expected } = testcases.HTTP.AUTH;
88 | const proxy = parser.parse(input.Surge);
89 | expect(proxy).eql(expected);
90 | });
91 |
92 | it('test https', function () {
93 | const { input, expected } = testcases.HTTP.TLS;
94 | const proxy = parser.parse(input.Surge);
95 | expect(proxy).eql(expected);
96 | });
97 | });
98 |
99 | describe('socks5', function () {
100 | it('test socks5 simple', function () {
101 | const { input, expected } = testcases.SOCKS5.SIMPLE;
102 | const proxy = parser.parse(input.Surge);
103 | expect(proxy).eql(expected);
104 | });
105 |
106 | it('test socks5 with authentication', function () {
107 | const { input, expected } = testcases.SOCKS5.AUTH;
108 | const proxy = parser.parse(input.Surge);
109 | expect(proxy).eql(expected);
110 | });
111 |
112 | it('test socks5 + tls', function () {
113 | const { input, expected } = testcases.SOCKS5.TLS;
114 | const proxy = parser.parse(input.Surge);
115 | expect(proxy).eql(expected);
116 | });
117 | });
118 |
119 | describe('snell', function () {
120 | it('test snell simple', function () {
121 | const { input, expected } = testcases.SNELL.SIMPLE;
122 | const proxy = parser.parse(input.Surge);
123 | expect(proxy).eql(expected);
124 | });
125 |
126 | it('test snell obfs + http', function () {
127 | const { input, expected } = testcases.SNELL.OBFS_HTTP;
128 | const proxy = parser.parse(input.Surge);
129 | expect(proxy).eql(expected);
130 | });
131 |
132 | it('test snell obfs + tls', function () {
133 | const { input, expected } = testcases.SNELL.OBFS_TLS;
134 | const proxy = parser.parse(input.Surge);
135 | expect(proxy).eql(expected);
136 | });
137 | });
138 | });
139 |
--------------------------------------------------------------------------------
/backend/src/utils/database.js:
--------------------------------------------------------------------------------
1 | export function findByName(list, name, field = 'name') {
2 | return list.find((item) => item[field] === name);
3 | }
4 |
5 | export function findIndexByName(list, name, field = 'name') {
6 | return list.findIndex((item) => item[field] === name);
7 | }
8 |
9 | export function deleteByName(list, name, field = 'name') {
10 | const idx = findIndexByName(list, name, field);
11 | list.splice(idx, 1);
12 | }
13 |
14 | export function updateByName(list, name, newItem, field = 'name') {
15 | const idx = findIndexByName(list, name, field);
16 | list[idx] = newItem;
17 | }
18 |
--------------------------------------------------------------------------------
/backend/src/utils/dns.js:
--------------------------------------------------------------------------------
1 | import $ from '@/core/app';
2 | import dnsPacket from 'dns-packet';
3 | import { Buffer } from 'buffer';
4 | import { isIPv4 } from '@/utils';
5 |
6 | export async function doh({ url, domain, type = 'A', timeout, edns }) {
7 | const buf = dnsPacket.encode({
8 | type: 'query',
9 | id: 0,
10 | flags: dnsPacket.RECURSION_DESIRED,
11 | questions: [
12 | {
13 | type,
14 | name: domain,
15 | },
16 | ],
17 | additionals: [
18 | {
19 | type: 'OPT',
20 | name: '.',
21 | udpPayloadSize: 4096,
22 | flags: 0,
23 | options: [
24 | {
25 | code: 'CLIENT_SUBNET',
26 | ip: edns,
27 | sourcePrefixLength: isIPv4(edns) ? 24 : 56,
28 | scopePrefixLength: 0,
29 | },
30 | ],
31 | },
32 | ],
33 | });
34 | const res = await $.http.get({
35 | url: `${url}?dns=${buf
36 | .toString('base64')
37 | .toString('utf-8')
38 | .replace(/=/g, '')}`,
39 | headers: {
40 | Accept: 'application/dns-message',
41 | // 'Content-Type': 'application/dns-message',
42 | },
43 | // body: buf,
44 | 'binary-mode': true,
45 | encoding: null, // 使用 null 编码以确保响应是原始二进制数据
46 | timeout,
47 | });
48 |
49 | return dnsPacket.decode(Buffer.from($.env.isQX ? res.bodyBytes : res.body));
50 | }
51 |
--------------------------------------------------------------------------------
/backend/src/utils/env.js:
--------------------------------------------------------------------------------
1 | import { version as substoreVersion } from '../../package.json';
2 | import { ENV } from '@/vendor/open-api';
3 |
4 | const {
5 | isNode,
6 | isQX,
7 | isLoon,
8 | isSurge,
9 | isStash,
10 | isShadowRocket,
11 | isLanceX,
12 | isEgern,
13 | isGUIforCores,
14 | } = ENV();
15 | let backend = 'Node';
16 | if (isNode) backend = 'Node';
17 | if (isQX) backend = 'QX';
18 | if (isLoon) backend = 'Loon';
19 | if (isSurge) backend = 'Surge';
20 | if (isStash) backend = 'Stash';
21 | if (isShadowRocket) backend = 'ShadowRocket';
22 | if (isEgern) backend = 'Egern';
23 | if (isLanceX) backend = 'LanceX';
24 | if (isGUIforCores) backend = 'GUI.for.Cores';
25 |
26 | let meta = {};
27 | let feature = {};
28 |
29 | try {
30 | if (typeof $environment !== 'undefined') {
31 | // eslint-disable-next-line no-undef
32 | meta.env = $environment;
33 | }
34 | if (typeof $loon !== 'undefined') {
35 | // eslint-disable-next-line no-undef
36 | meta.loon = $loon;
37 | }
38 | if (typeof $script !== 'undefined') {
39 | // eslint-disable-next-line no-undef
40 | meta.script = $script;
41 | }
42 | if (typeof $Plugin !== 'undefined') {
43 | // eslint-disable-next-line no-undef
44 | meta.plugin = $Plugin;
45 | }
46 | if (isNode) {
47 | meta.node = {
48 | version: eval('process.version'),
49 | argv: eval('process.argv'),
50 | filename: eval('__filename'),
51 | dirname: eval('__dirname'),
52 | env: {},
53 | };
54 | const env = eval('process.env');
55 | for (const key in env) {
56 | if (/^SUB_STORE_/.test(key)) {
57 | meta.node.env[key] = env[key];
58 | }
59 | }
60 | }
61 | // eslint-disable-next-line no-empty
62 | } catch (e) {}
63 |
64 | export default {
65 | backend,
66 | version: substoreVersion,
67 | feature,
68 | meta,
69 | };
70 |
--------------------------------------------------------------------------------
/backend/src/utils/headers-resource-cache.js:
--------------------------------------------------------------------------------
1 | import $ from '@/core/app';
2 | import {
3 | HEADERS_RESOURCE_CACHE_KEY,
4 | CHR_EXPIRATION_TIME_KEY,
5 | } from '@/constants';
6 |
7 | class ResourceCache {
8 | constructor() {
9 | this.expires = getExpiredTime();
10 | if (!$.read(HEADERS_RESOURCE_CACHE_KEY)) {
11 | $.write('{}', HEADERS_RESOURCE_CACHE_KEY);
12 | }
13 | try {
14 | this.resourceCache = JSON.parse($.read(HEADERS_RESOURCE_CACHE_KEY));
15 | } catch (e) {
16 | $.error(
17 | `解析持久化缓存中的 ${HEADERS_RESOURCE_CACHE_KEY} 失败, 重置为 {}, 错误: ${
18 | e?.message ?? e
19 | }`,
20 | );
21 | this.resourceCache = {};
22 | $.write('{}', HEADERS_RESOURCE_CACHE_KEY);
23 | }
24 | this._cleanup();
25 | }
26 |
27 | _cleanup() {
28 | // clear obsolete cached resource
29 | let clear = false;
30 | Object.entries(this.resourceCache).forEach((entry) => {
31 | const [id, updated] = entry;
32 | if (!updated.time) {
33 | // clear old version cache
34 | delete this.resourceCache[id];
35 | $.delete(`#${id}`);
36 | clear = true;
37 | }
38 | if (new Date().getTime() - updated.time > this.expires) {
39 | delete this.resourceCache[id];
40 | clear = true;
41 | }
42 | });
43 | if (clear) this._persist();
44 | }
45 |
46 | revokeAll() {
47 | this.resourceCache = {};
48 | this._persist();
49 | }
50 |
51 | _persist() {
52 | $.write(JSON.stringify(this.resourceCache), HEADERS_RESOURCE_CACHE_KEY);
53 | }
54 |
55 | get(id) {
56 | const updated = this.resourceCache[id] && this.resourceCache[id].time;
57 | if (updated && new Date().getTime() - updated <= this.expires) {
58 | return this.resourceCache[id].data;
59 | }
60 | return null;
61 | }
62 |
63 | gettime(id) {
64 | const updated = this.resourceCache[id] && this.resourceCache[id].time;
65 | if (updated && new Date().getTime() - updated <= this.expires) {
66 | return this.resourceCache[id].time;
67 | }
68 | return null;
69 | }
70 |
71 | set(id, value) {
72 | this.resourceCache[id] = { time: new Date().getTime(), data: value };
73 | this._persist();
74 | }
75 | }
76 |
77 | function getExpiredTime() {
78 | // console.log($.read(CHR_EXPIRATION_TIME_KEY));
79 | if (!$.read(CHR_EXPIRATION_TIME_KEY)) {
80 | $.write('6e4', CHR_EXPIRATION_TIME_KEY); // 1分钟
81 | }
82 | let expiration = 6e4;
83 | if ($.env.isLoon) {
84 | const loont = {
85 | // Loon 插件自义定
86 | '1\u5206\u949f': 6e4,
87 | '5\u5206\u949f': 3e5,
88 | '10\u5206\u949f': 6e5,
89 | '30\u5206\u949f': 18e5, // "30分钟"
90 | '1\u5c0f\u65f6': 36e5,
91 | '2\u5c0f\u65f6': 72e5,
92 | '3\u5c0f\u65f6': 108e5,
93 | '6\u5c0f\u65f6': 216e5,
94 | '12\u5c0f\u65f6': 432e5,
95 | '24\u5c0f\u65f6': 864e5,
96 | '48\u5c0f\u65f6': 1728e5,
97 | '72\u5c0f\u65f6': 2592e5, // "72小时"
98 | '\u53c2\u6570\u4f20\u5165': 'readcachets', // "参数输入"
99 | };
100 | let intimed = $.read(
101 | '#\u54cd\u5e94\u5934\u7f13\u5b58\u6709\u6548\u671f',
102 | ); // Loon #响应头缓存有效期
103 | // console.log(intimed);
104 | if (intimed in loont) {
105 | expiration = loont[intimed];
106 | if (expiration === 'readcachets') {
107 | expiration = intimed;
108 | }
109 | }
110 | return expiration;
111 | } else {
112 | expiration = $.read(CHR_EXPIRATION_TIME_KEY);
113 | return expiration;
114 | }
115 | }
116 |
117 | export default new ResourceCache();
118 |
--------------------------------------------------------------------------------
/backend/src/utils/index.js:
--------------------------------------------------------------------------------
1 | import * as ipAddress from 'ip-address';
2 | // source: https://stackoverflow.com/a/36760050
3 | const IPV4_REGEX = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)(\.(?!$)|$)){4}$/;
4 |
5 | // source: https://ihateregex.io/expr/ipv6/
6 | const IPV6_REGEX =
7 | /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
8 |
9 | function isIPv4(ip) {
10 | return IPV4_REGEX.test(ip);
11 | }
12 |
13 | function isIPv6(ip) {
14 | return IPV6_REGEX.test(ip);
15 | }
16 |
17 | function isValidPortNumber(port) {
18 | return /^((6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0-9]{1,4}))$/.test(
19 | port,
20 | );
21 | }
22 |
23 | function isNotBlank(str) {
24 | return typeof str === 'string' && str.trim().length > 0;
25 | }
26 |
27 | function getIfNotBlank(str, defaultValue) {
28 | return isNotBlank(str) ? str : defaultValue;
29 | }
30 |
31 | function isPresent(obj) {
32 | return typeof obj !== 'undefined' && obj !== null;
33 | }
34 |
35 | function getIfPresent(obj, defaultValue) {
36 | return isPresent(obj) ? obj : defaultValue;
37 | }
38 |
39 | function getPolicyDescriptor(str) {
40 | if (!str) return {};
41 | return /^.+?\s*?=\s*?.+?\s*?,.+?/.test(str)
42 | ? {
43 | 'policy-descriptor': str,
44 | }
45 | : {
46 | policy: str,
47 | };
48 | }
49 |
50 | // const utf8ArrayToStr =
51 | // typeof TextDecoder !== 'undefined'
52 | // ? (v) => new TextDecoder().decode(new Uint8Array(v))
53 | // : (function () {
54 | // var charCache = new Array(128); // Preallocate the cache for the common single byte chars
55 | // var charFromCodePt = String.fromCodePoint || String.fromCharCode;
56 | // var result = [];
57 |
58 | // return function (array) {
59 | // var codePt, byte1;
60 | // var buffLen = array.length;
61 |
62 | // result.length = 0;
63 |
64 | // for (var i = 0; i < buffLen; ) {
65 | // byte1 = array[i++];
66 |
67 | // if (byte1 <= 0x7f) {
68 | // codePt = byte1;
69 | // } else if (byte1 <= 0xdf) {
70 | // codePt = ((byte1 & 0x1f) << 6) | (array[i++] & 0x3f);
71 | // } else if (byte1 <= 0xef) {
72 | // codePt =
73 | // ((byte1 & 0x0f) << 12) |
74 | // ((array[i++] & 0x3f) << 6) |
75 | // (array[i++] & 0x3f);
76 | // } else if (String.fromCodePoint) {
77 | // codePt =
78 | // ((byte1 & 0x07) << 18) |
79 | // ((array[i++] & 0x3f) << 12) |
80 | // ((array[i++] & 0x3f) << 6) |
81 | // (array[i++] & 0x3f);
82 | // } else {
83 | // codePt = 63; // Cannot convert four byte code points, so use "?" instead
84 | // i += 3;
85 | // }
86 |
87 | // result.push(
88 | // charCache[codePt] ||
89 | // (charCache[codePt] = charFromCodePt(codePt)),
90 | // );
91 | // }
92 |
93 | // return result.join('');
94 | // };
95 | // })();
96 |
97 | function getRandomInt(min, max) {
98 | min = Math.ceil(min);
99 | max = Math.floor(max);
100 | return Math.floor(Math.random() * (max - min + 1)) + min;
101 | }
102 |
103 | function getRandomPort(portString) {
104 | let portParts = portString.split(/,|\//);
105 | let randomPart = portParts[Math.floor(Math.random() * portParts.length)];
106 | if (randomPart.includes('-')) {
107 | let [min, max] = randomPart.split('-').map(Number);
108 | return getRandomInt(min, max);
109 | } else {
110 | return Number(randomPart);
111 | }
112 | }
113 |
114 | function numberToString(value) {
115 | return Number.isSafeInteger(value)
116 | ? String(value)
117 | : BigInt(value).toString();
118 | }
119 |
120 | function isValidUUID(uuid) {
121 | return (
122 | typeof uuid === 'string' &&
123 | /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(
124 | uuid,
125 | )
126 | );
127 | }
128 |
129 | function formatDateTime(date, format = 'YYYY-MM-DD_HH-mm-ss') {
130 | const d = date instanceof Date ? date : new Date(date);
131 |
132 | if (isNaN(d.getTime())) {
133 | return '';
134 | }
135 |
136 | const pad = (num) => String(num).padStart(2, '0');
137 |
138 | const replacements = {
139 | YYYY: d.getFullYear(),
140 | MM: pad(d.getMonth() + 1),
141 | DD: pad(d.getDate()),
142 | HH: pad(d.getHours()),
143 | mm: pad(d.getMinutes()),
144 | ss: pad(d.getSeconds()),
145 | };
146 |
147 | return format.replace(
148 | /YYYY|MM|DD|HH|mm|ss/g,
149 | (match) => replacements[match],
150 | );
151 | }
152 |
153 | export {
154 | formatDateTime,
155 | isValidUUID,
156 | ipAddress,
157 | isIPv4,
158 | isIPv6,
159 | isValidPortNumber,
160 | isNotBlank,
161 | getIfNotBlank,
162 | isPresent,
163 | getIfPresent,
164 | // utf8ArrayToStr,
165 | getPolicyDescriptor,
166 | getRandomPort,
167 | numberToString,
168 | };
169 |
--------------------------------------------------------------------------------
/backend/src/utils/logical.js:
--------------------------------------------------------------------------------
1 | function AND(...args) {
2 | return args.reduce((a, b) => a.map((c, i) => b[i] && c));
3 | }
4 |
5 | function OR(...args) {
6 | return args.reduce((a, b) => a.map((c, i) => b[i] || c));
7 | }
8 |
9 | function NOT(array) {
10 | return array.map((c) => !c);
11 | }
12 |
13 | function FULL(length, bool) {
14 | return [...Array(length).keys()].map(() => bool);
15 | }
16 |
17 | export { AND, OR, NOT, FULL };
18 |
--------------------------------------------------------------------------------
/backend/src/utils/migration.js:
--------------------------------------------------------------------------------
1 | import {
2 | SUBS_KEY,
3 | COLLECTIONS_KEY,
4 | SCHEMA_VERSION_KEY,
5 | ARTIFACTS_KEY,
6 | RULES_KEY,
7 | FILES_KEY,
8 | TOKENS_KEY,
9 | } from '@/constants';
10 | import $ from '@/core/app';
11 |
12 | export default function migrate() {
13 | migrateV2();
14 | }
15 |
16 | function migrateV2() {
17 | const version = $.read(SCHEMA_VERSION_KEY);
18 | if (!version) doMigrationV2();
19 |
20 | // write the current version
21 | if (version !== '2.0') {
22 | $.write('2.0', SCHEMA_VERSION_KEY);
23 | }
24 | }
25 |
26 | function doMigrationV2() {
27 | $.info('Start migrating...');
28 | // 1. migrate subscriptions
29 | const subs = $.read(SUBS_KEY) || {};
30 | const newSubs = Object.values(subs).map((sub) => {
31 | // set default source to remote
32 | sub.source = sub.source || 'remote';
33 |
34 | migrateDisplayName(sub);
35 | migrateProcesses(sub);
36 | return sub;
37 | });
38 | $.write(newSubs, SUBS_KEY);
39 |
40 | // 2. migrate collections
41 | const collections = $.read(COLLECTIONS_KEY) || {};
42 | const newCollections = Object.values(collections).map((collection) => {
43 | delete collection.ua;
44 | migrateDisplayName(collection);
45 | migrateProcesses(collection);
46 | return collection;
47 | });
48 | $.write(newCollections, COLLECTIONS_KEY);
49 |
50 | // 3. migrate artifacts
51 | const artifacts = $.read(ARTIFACTS_KEY) || {};
52 | const newArtifacts = Object.values(artifacts);
53 | $.write(newArtifacts, ARTIFACTS_KEY);
54 |
55 | // 4. migrate rules
56 | const rules = $.read(RULES_KEY) || {};
57 | const newRules = Object.values(rules);
58 | $.write(newRules, RULES_KEY);
59 |
60 | // 5. migrate files
61 | const files = $.read(FILES_KEY) || {};
62 | const newFiles = Object.values(files);
63 | $.write(newFiles, FILES_KEY);
64 |
65 | // 6. migrate tokens
66 | const tokens = $.read(TOKENS_KEY) || {};
67 | const newTokens = Object.values(tokens);
68 | $.write(newTokens, TOKENS_KEY);
69 |
70 | // 7. delete builtin rules
71 | delete $.cache.builtin;
72 | $.info('Migration complete!');
73 |
74 | function migrateDisplayName(item) {
75 | const displayName = item['display-name'];
76 | if (displayName) {
77 | item.displayName = displayName;
78 | delete item['display-name'];
79 | }
80 | }
81 |
82 | function migrateProcesses(item) {
83 | const processes = item.process;
84 | if (!processes || processes.length === 0) return;
85 | const newProcesses = [];
86 | const quickSettingOperator = {
87 | type: 'Quick Setting Operator',
88 | args: {
89 | udp: 'DEFAULT',
90 | tfo: 'DEFAULT',
91 | scert: 'DEFAULT',
92 | 'vmess aead': 'DEFAULT',
93 | useless: 'DEFAULT',
94 | },
95 | };
96 | for (const p of processes) {
97 | if (!p.type) continue;
98 | if (p.type === 'Useless Filter') {
99 | quickSettingOperator.args.useless = 'ENABLED';
100 | } else if (p.type === 'Set Property Operator') {
101 | const { key, value } = p.args;
102 | switch (key) {
103 | case 'udp':
104 | quickSettingOperator.args.udp = value
105 | ? 'ENABLED'
106 | : 'DISABLED';
107 | break;
108 | case 'tfo':
109 | quickSettingOperator.args.tfo = value
110 | ? 'ENABLED'
111 | : 'DISABLED';
112 | break;
113 | case 'skip-cert-verify':
114 | quickSettingOperator.args.scert = value
115 | ? 'ENABLED'
116 | : 'DISABLED';
117 | break;
118 | case 'aead':
119 | quickSettingOperator.args['vmess aead'] = value
120 | ? 'ENABLED'
121 | : 'DISABLED';
122 | break;
123 | }
124 | } else if (p.type.indexOf('Keyword') !== -1) {
125 | // drop keyword operators and keyword filters
126 | } else if (p.type === 'Flag Operator') {
127 | // set default args
128 | const add = typeof p.args === 'undefined' ? true : p.args;
129 | p.args = {
130 | mode: add ? 'add' : 'remove',
131 | };
132 | newProcesses.push(p);
133 | } else {
134 | newProcesses.push(p);
135 | }
136 | }
137 | newProcesses.unshift(quickSettingOperator);
138 | item.process = newProcesses;
139 | }
140 | }
141 |
--------------------------------------------------------------------------------
/backend/src/utils/resource-cache.js:
--------------------------------------------------------------------------------
1 | import $ from '@/core/app';
2 | import { CACHE_EXPIRATION_TIME_MS, RESOURCE_CACHE_KEY } from '@/constants';
3 |
4 | class ResourceCache {
5 | constructor(expires) {
6 | this.expires = expires;
7 | if (!$.read(RESOURCE_CACHE_KEY)) {
8 | $.write('{}', RESOURCE_CACHE_KEY);
9 | }
10 | try {
11 | this.resourceCache = JSON.parse($.read(RESOURCE_CACHE_KEY));
12 | } catch (e) {
13 | $.error(
14 | `解析持久化缓存中的 ${RESOURCE_CACHE_KEY} 失败, 重置为 {}, 错误: ${
15 | e?.message ?? e
16 | }`,
17 | );
18 | this.resourceCache = {};
19 | $.write('{}', RESOURCE_CACHE_KEY);
20 | }
21 | this._cleanup();
22 | }
23 |
24 | _cleanup() {
25 | // clear obsolete cached resource
26 | let clear = false;
27 | Object.entries(this.resourceCache).forEach((entry) => {
28 | const [id, updated] = entry;
29 | if (!updated.time) {
30 | // clear old version cache
31 | delete this.resourceCache[id];
32 | $.delete(`#${id}`);
33 | clear = true;
34 | }
35 | if (new Date().getTime() - updated.time > this.expires) {
36 | delete this.resourceCache[id];
37 | clear = true;
38 | }
39 | });
40 | if (clear) this._persist();
41 | }
42 |
43 | revokeAll() {
44 | this.resourceCache = {};
45 | this._persist();
46 | }
47 |
48 | _persist() {
49 | $.write(JSON.stringify(this.resourceCache), RESOURCE_CACHE_KEY);
50 | }
51 |
52 | get(id) {
53 | const updated = this.resourceCache[id] && this.resourceCache[id].time;
54 | if (updated && new Date().getTime() - updated <= this.expires) {
55 | return this.resourceCache[id].data;
56 | }
57 | return null;
58 | }
59 |
60 | set(id, value) {
61 | this.resourceCache[id] = { time: new Date().getTime(), data: value };
62 | this._persist();
63 | }
64 | }
65 |
66 | export default new ResourceCache(CACHE_EXPIRATION_TIME_MS);
67 |
--------------------------------------------------------------------------------
/backend/src/utils/rs.js:
--------------------------------------------------------------------------------
1 | import rs from 'jsrsasign';
2 |
3 | export function generateFingerprint(caStr) {
4 | const hex = rs.pemtohex(caStr);
5 | const fingerPrint = rs.KJUR.crypto.Util.hashHex(hex, 'sha256');
6 | return fingerPrint.match(/.{2}/g).join(':').toUpperCase();
7 | }
8 |
9 | export default {
10 | generateFingerprint,
11 | };
12 |
--------------------------------------------------------------------------------
/backend/src/utils/script-resource-cache.js:
--------------------------------------------------------------------------------
1 | import $ from '@/core/app';
2 | import {
3 | SCRIPT_RESOURCE_CACHE_KEY,
4 | CSR_EXPIRATION_TIME_KEY,
5 | } from '@/constants';
6 |
7 | class ResourceCache {
8 | constructor() {
9 | this.expires = getExpiredTime();
10 | if (!$.read(SCRIPT_RESOURCE_CACHE_KEY)) {
11 | $.write('{}', SCRIPT_RESOURCE_CACHE_KEY);
12 | }
13 | try {
14 | this.resourceCache = JSON.parse($.read(SCRIPT_RESOURCE_CACHE_KEY));
15 | } catch (e) {
16 | $.error(
17 | `解析持久化缓存中的 ${SCRIPT_RESOURCE_CACHE_KEY} 失败, 重置为 {}, 错误: ${
18 | e?.message ?? e
19 | }`,
20 | );
21 | this.resourceCache = {};
22 | $.write('{}', SCRIPT_RESOURCE_CACHE_KEY);
23 | }
24 | this._cleanup();
25 | }
26 |
27 | _cleanup(prefix, expires) {
28 | // clear obsolete cached resource
29 | let clear = false;
30 | Object.entries(this.resourceCache).forEach((entry) => {
31 | const [id, updated] = entry;
32 | if (!updated.time) {
33 | // clear old version cache
34 | delete this.resourceCache[id];
35 | $.delete(`#${id}`);
36 | clear = true;
37 | }
38 | if (
39 | new Date().getTime() - updated.time >
40 | (expires ?? this.expires) ||
41 | (prefix && id.startsWith(prefix))
42 | ) {
43 | delete this.resourceCache[id];
44 | clear = true;
45 | }
46 | });
47 | if (clear) this._persist();
48 | }
49 |
50 | revokeAll() {
51 | this.resourceCache = {};
52 | this._persist();
53 | }
54 |
55 | _persist() {
56 | $.write(JSON.stringify(this.resourceCache), SCRIPT_RESOURCE_CACHE_KEY);
57 | }
58 |
59 | get(id, expires, remove) {
60 | const updated = this.resourceCache[id] && this.resourceCache[id].time;
61 | if (updated) {
62 | if (new Date().getTime() - updated <= (expires ?? this.expires))
63 | return this.resourceCache[id].data;
64 | if (remove) {
65 | delete this.resourceCache[id];
66 | this._persist();
67 | }
68 | }
69 | return null;
70 | }
71 |
72 | gettime(id) {
73 | const updated = this.resourceCache[id] && this.resourceCache[id].time;
74 | if (updated && new Date().getTime() - updated <= this.expires) {
75 | return this.resourceCache[id].time;
76 | }
77 | return null;
78 | }
79 |
80 | set(id, value) {
81 | this.resourceCache[id] = { time: new Date().getTime(), data: value };
82 | this._persist();
83 | }
84 | }
85 |
86 | function getExpiredTime() {
87 | // console.log($.read(CSR_EXPIRATION_TIME_KEY));
88 | if (!$.read(CSR_EXPIRATION_TIME_KEY)) {
89 | $.write('1728e5', CSR_EXPIRATION_TIME_KEY); // 48 * 3600 * 1000
90 | }
91 | let expiration = 1728e5;
92 | if ($.env.isLoon) {
93 | const loont = {
94 | // Loon 插件自义定
95 | '1\u5206\u949f': 6e4,
96 | '5\u5206\u949f': 3e5,
97 | '10\u5206\u949f': 6e5,
98 | '30\u5206\u949f': 18e5, // "30分钟"
99 | '1\u5c0f\u65f6': 36e5,
100 | '2\u5c0f\u65f6': 72e5,
101 | '3\u5c0f\u65f6': 108e5,
102 | '6\u5c0f\u65f6': 216e5,
103 | '12\u5c0f\u65f6': 432e5,
104 | '24\u5c0f\u65f6': 864e5,
105 | '48\u5c0f\u65f6': 1728e5,
106 | '72\u5c0f\u65f6': 2592e5, // "72小时"
107 | '\u53c2\u6570\u4f20\u5165': 'readcachets', // "参数输入"
108 | };
109 | let intimed = $.read('#\u8282\u70b9\u7f13\u5b58\u6709\u6548\u671f'); // Loon #节点缓存有效期
110 | // console.log(intimed);
111 | if (intimed in loont) {
112 | expiration = loont[intimed];
113 | if (expiration === 'readcachets') {
114 | expiration = intimed;
115 | }
116 | }
117 | return expiration;
118 | } else {
119 | expiration = $.read(CSR_EXPIRATION_TIME_KEY);
120 | return expiration;
121 | }
122 | }
123 |
124 | export default new ResourceCache();
125 |
--------------------------------------------------------------------------------
/backend/src/utils/user-agent.js:
--------------------------------------------------------------------------------
1 | import gte from 'semver/functions/gte';
2 | import coerce from 'semver/functions/coerce';
3 | import $ from '@/core/app';
4 |
5 | export function getUserAgentFromHeaders(headers) {
6 | const keys = Object.keys(headers);
7 | let UA = '';
8 | let ua = '';
9 | let accept = '';
10 | for (let k of keys) {
11 | const lower = k.toLowerCase();
12 | if (lower === 'user-agent') {
13 | UA = headers[k];
14 | ua = UA.toLowerCase();
15 | } else if (lower === 'accept') {
16 | accept = headers[k];
17 | }
18 | }
19 | return { UA, ua, accept };
20 | }
21 |
22 | export function getPlatformFromUserAgent({ ua, UA, accept }) {
23 | if (UA.indexOf('Quantumult%20X') !== -1) {
24 | return 'QX';
25 | } else if (ua.indexOf('egern') !== -1) {
26 | return 'Egern';
27 | } else if (UA.indexOf('Surfboard') !== -1) {
28 | return 'Surfboard';
29 | } else if (UA.indexOf('Surge Mac') !== -1) {
30 | return 'SurgeMac';
31 | } else if (UA.indexOf('Surge') !== -1) {
32 | return 'Surge';
33 | } else if (UA.indexOf('Decar') !== -1 || UA.indexOf('Loon') !== -1) {
34 | return 'Loon';
35 | } else if (UA.indexOf('Shadowrocket') !== -1) {
36 | return 'Shadowrocket';
37 | } else if (UA.indexOf('Stash') !== -1) {
38 | return 'Stash';
39 | } else if (
40 | ua === 'meta' ||
41 | (ua.indexOf('clash') !== -1 && ua.indexOf('meta') !== -1) ||
42 | ua.indexOf('clash-verge') !== -1 ||
43 | ua.indexOf('flclash') !== -1
44 | ) {
45 | return 'ClashMeta';
46 | } else if (ua.indexOf('clash') !== -1) {
47 | return 'Clash';
48 | } else if (ua.indexOf('v2ray') !== -1) {
49 | return 'V2Ray';
50 | } else if (ua.indexOf('sing-box') !== -1 || ua.indexOf('singbox') !== -1) {
51 | return 'sing-box';
52 | } else if (accept.indexOf('application/json') === 0) {
53 | return 'JSON';
54 | } else {
55 | return 'V2Ray';
56 | }
57 | }
58 |
59 | export function getPlatformFromHeaders(headers) {
60 | const { UA, ua, accept } = getUserAgentFromHeaders(headers);
61 | return getPlatformFromUserAgent({ ua, UA, accept });
62 | }
63 | export function shouldIncludeUnsupportedProxy(platform, ua) {
64 | // try {
65 | // const target = getPlatformFromUserAgent({
66 | // UA: ua,
67 | // ua: ua.toLowerCase(),
68 | // });
69 | // if (!['Stash', 'Egern', 'Loon'].includes(target)) {
70 | // return false;
71 | // }
72 | // const coerceVersion = coerce(ua);
73 | // $.log(JSON.stringify(coerceVersion, null, 2));
74 | // const { version } = coerceVersion;
75 | // if (
76 | // platform === 'Stash' &&
77 | // target === 'Stash' &&
78 | // gte(version, '3.1.0')
79 | // ) {
80 | // return true;
81 | // }
82 | // if (
83 | // platform === 'Egern' &&
84 | // target === 'Egern' &&
85 | // gte(version, '1.29.0')
86 | // ) {
87 | // return true;
88 | // }
89 | // // Loon 的 UA 不规范, version 取出来是 build
90 | // if (
91 | // platform === 'Loon' &&
92 | // target === 'Loon' &&
93 | // gte(version, '842.0.0')
94 | // ) {
95 | // return true;
96 | // }
97 | // } catch (e) {
98 | // $.error(`获取版本号失败: ${e}`);
99 | // }
100 | return false;
101 | }
102 |
--------------------------------------------------------------------------------
/backend/src/utils/yaml.js:
--------------------------------------------------------------------------------
1 | import YAML from 'static-js-yaml';
2 |
3 | function retry(fn, content, ...args) {
4 | try {
5 | return fn(content, ...args);
6 | } catch (e) {
7 | return fn(
8 | dump(
9 | fn(
10 | content.replace(/!\s*/g, '__SubStoreJSYAMLString__'),
11 | ...args,
12 | ),
13 | ).replace(/__SubStoreJSYAMLString__/g, ''),
14 | ...args,
15 | );
16 | }
17 | }
18 |
19 | export function safeLoad(content, ...args) {
20 | return retry(YAML.safeLoad, JSON.parse(JSON.stringify(content)), ...args);
21 | }
22 | export function load(content, ...args) {
23 | return retry(YAML.load, JSON.parse(JSON.stringify(content)), ...args);
24 | }
25 | export function safeDump(content, ...args) {
26 | return YAML.safeDump(JSON.parse(JSON.stringify(content)), ...args);
27 | }
28 | export function dump(content, ...args) {
29 | return YAML.dump(JSON.parse(JSON.stringify(content)), ...args);
30 | }
31 |
32 | export default {
33 | safeLoad,
34 | load,
35 | safeDump,
36 | dump,
37 | parse: safeLoad,
38 | stringify: safeDump,
39 | };
40 |
--------------------------------------------------------------------------------
/backend/sub-store_1748083027961.json:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sub-store-org/Sub-Store/4aafdaaddbc7ba24651501eaaa25da69219084d9/backend/sub-store_1748083027961.json
--------------------------------------------------------------------------------
/config/Egern.yaml:
--------------------------------------------------------------------------------
1 | name: Sub-Store
2 | description: '支持 Surge 正式版的参数设置功能. 测落地功能 ability: http-client-policy, 同步配置的定时 cronexp: 55 23 * * *'
3 | compat_arguments:
4 | ability: http-client-policy
5 | cronexp: 55 23 * * *
6 | sync: '"Sub-Store Sync"'
7 | timeout: '120'
8 | engine: auto
9 | produce: '"# Sub-Store Produce"'
10 | produce_cronexp: 50 */6 * * *
11 | produce_sub: '"sub1,sub2"'
12 | produce_col: '"col1,col2"'
13 | compat_arguments_desc: '\n1️⃣ ability\n\n默认已开启测落地能力\n需要配合脚本操作\n如 https://raw.githubusercontent.com/Keywos/rule/main/cname.js\n填写任意其他值关闭\n\n2️⃣ cronexp\n\n同步配置定时任务\n默认为每天 23 点 55 分\n\n定时任务指定时将订阅/文件上传到私有 Gist. 在前端, 叫做 ''同步'' 或 ''同步配置''\n\n3️⃣ sync\n\n自定义定时任务名\n便于在脚本编辑器中选择\n若设为 # 可取消定时任务\n\n4️⃣ timeout\n\n脚本超时, 单位为秒\n\n5️⃣ engine\n\n默认为自动使用 webview 引擎, 可设为指定 jsc, 但 jsc 容易爆内存\n\n6️⃣ produce\n\n自定义处理订阅的定时任务名\n一般用于定时处理耗时较长的订阅, 以更新缓存\n这样 Surge 中拉取的时候就能用到缓存, 不至于总是超时\n若设为 # 可取消此定时任务\n默认不开启\n\n7️⃣ produce_cronexp\n\n配置处理订阅的定时任务\n\n默认为每 6 小时\n\n9️⃣ produce_sub\n\n自定义需定时处理的单条订阅名\n多个用 , 连接\n\n🔟 produce_col\n\n自定义需定时处理的组合订阅名\n多个用 , 连接\n\n⚠️ 注意: 是 名称(name) 不是 显示名称(displayName)\n如果名称需要编码, 请编码后再用 , 连接\n顺序: 并发执行单条订阅, 然后并发执行组合订阅'
14 | scriptings:
15 | - http_request:
16 | name: Sub-Store Core
17 | match: ^https?:\/\/sub\.store\/((download)|api\/(preview|sync|(utils\/node-info)))
18 | script_url: https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/sub-store-1.min.js
19 | body_required: true
20 | - http_request:
21 | name: Sub-Store Simple
22 | match: ^https?:\/\/sub\.store
23 | script_url: https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/sub-store-0.min.js
24 | body_required: true
25 | - schedule:
26 | name: '{{{sync}}}'
27 | cron: '{{{cronexp}}}'
28 | script_url: https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/cron-sync-artifacts.min.js
29 | - schedule:
30 | name: '{{{produce}}}'
31 | cron: '{{{produce_cronexp}}}'
32 | script_url: https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/cron-sync-artifacts.min.js
33 | arguments:
34 | _compat.$argument: '"sub={{{produce_sub}}}&col={{{produce_col}}}"'
35 | mitm:
36 | hostnames:
37 | includes:
38 | - sub.store
39 |
--------------------------------------------------------------------------------
/config/Loon.plugin:
--------------------------------------------------------------------------------
1 | #!name=Sub-Store
2 | #!desc=高级订阅管理工具. 定时任务默认为每天 23 点 55 分. 定时任务指定时将订阅/文件上传到私有 Gist. 在前端, 叫做 '同步' 或 '同步配置'
3 | #!openUrl=https://sub.store
4 | #!author=Peng-YM
5 | #!homepage=https://github.com/sub-store-org/Sub-Store
6 | #!icon=https://raw.githubusercontent.com/58xinian/icon/master/Sub-Store1.png
7 | #!select = 节点缓存有效期,1分钟,5分钟,10分钟,30分钟,1小时,2小时,3小时,6小时,12小时,24小时,48小时,72小时,参数传入
8 | #!select = 响应头缓存有效期,1分钟,5分钟,10分钟,30分钟,1小时,2小时,3小时,6小时,12小时,24小时,48小时,72小时,参数传入
9 |
10 | [Rule]
11 | DOMAIN,sub-store.vercel.app,PROXY
12 |
13 | [MITM]
14 | hostname=sub.store
15 |
16 | [Script]
17 | http-request ^https?:\/\/sub\.store\/((download)|api\/(preview|sync|(utils\/node-info))) script-path=https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/sub-store-1.min.js, requires-body=true, timeout=120, tag=Sub-Store Core
18 | http-request ^https?:\/\/sub\.store script-path=https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/sub-store-0.min.js, requires-body=true, timeout=120, tag=Sub-Store Simple
19 |
20 | cron "55 23 * * *" script-path=https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/cron-sync-artifacts.min.js, timeout=120, tag=Sub-Store Sync
--------------------------------------------------------------------------------
/config/QX-Task.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Sub-Store",
3 | "description": "定时任务默认为每天 23 点 55 分. 定时任务指定时将订阅/文件上传到私有 Gist. 在前端, 叫做 '同步' 或 '同步配置'",
4 | "task": [
5 | "55 23 * * * https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/cron-sync-artifacts.min.js, tag=Sub-Store Sync, img-url=https://raw.githubusercontent.com/58xinian/icon/master/Sub-Store1.png"
6 | ]
7 | }
--------------------------------------------------------------------------------
/config/QX.snippet:
--------------------------------------------------------------------------------
1 | hostname=sub.store
2 |
3 | ^https?:\/\/sub\.store\/((download)|api\/(preview|sync|(utils\/node-info))) url script-analyze-echo-response https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/sub-store-1.min.js
4 | ^https?:\/\/sub\.store url script-analyze-echo-response https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/sub-store-0.min.js
--------------------------------------------------------------------------------
/config/README.md:
--------------------------------------------------------------------------------
1 | # Sub-Store 配置指南
2 |
3 | ## 查看更新说明:
4 |
5 | Sub-Store Releases: [`https://github.com/sub-store-org/Sub-Store/releases`](https://github.com/sub-store-org/Sub-Store/releases)
6 |
7 | Telegram 频道: [`https://t.me/cool_scripts` ](https://t.me/cool_scripts)
8 |
9 | ## 服务器/云平台/Docker/Android 版
10 |
11 | https://xream.notion.site/Sub-Store-abe6a96944724dc6a36833d5c9ab7c87
12 |
13 | ## App 版
14 |
15 | ### 1. Loon
16 | 安装使用 插件 [`https://raw.githubusercontent.com/sub-store-org/Sub-Store/master/config/Loon.plugin`](https://raw.githubusercontent.com/sub-store-org/Sub-Store/master/config/Loon.plugin) 即可。
17 |
18 | ### 2. Surge
19 |
20 | #### 关于 Surge 的格外说明
21 |
22 | Surge Mac 版如何支持 SSR, 如何去除 HTTP 传输层以支持 类似 VMess HTTP 节点等 请查看 [链接参数说明](https://github.com/sub-store-org/Sub-Store/wiki/%E9%93%BE%E6%8E%A5%E5%8F%82%E6%95%B0%E8%AF%B4%E6%98%8E)
23 |
24 | 定时处理订阅 功能, 避免 App 内拉取超时, 请查看 [定时处理订阅](https://t.me/zhetengsha/1449)
25 |
26 | 0. 最新 Surge iOS TestFlight 版本 可使用 Beta 版(支持最新 Surge iOS TestFlight 版本的特性): [`https://raw.githubusercontent.com/sub-store-org/Sub-Store/master/config/Surge-Beta.sgmodule`](https://raw.githubusercontent.com/sub-store-org/Sub-Store/master/config/Surge-Beta.sgmodule)
27 |
28 | 1. 官方默认版模块(支持 App 内使用编辑参数): [`https://raw.githubusercontent.com/sub-store-org/Sub-Store/master/config/Surge.sgmodule`](https://raw.githubusercontent.com/sub-store-org/Sub-Store/master/config/Surge.sgmodule)
29 |
30 | > 最新版 Surge 已删除 `ability: http-client-policy` 参数, 模块暂不做修改, 对测落地功能无影响
31 |
32 | 2. 经典版, 不支持编辑参数, 固定带 ability 参数版本, 使用 jsc 引擎时, 可能会爆内存, 如果需要使用指定节点功能 例如[加旗帜脚本或者cname脚本] 请使用此带 ability 参数版本: [`https://raw.githubusercontent.com/sub-store-org/Sub-Store/master/config/Surge-ability.sgmodule`](https://raw.githubusercontent.com/sub-store-org/Sub-Store/master/config/Surge-ability.sgmodule)
33 |
34 | 3. 经典版, 不支持编辑参数, 固定不带 ability 参数版本: [`https://raw.githubusercontent.com/sub-store-org/Sub-Store/master/config/Surge-Noability.sgmodule`](https://raw.githubusercontent.com/sub-store-org/Sub-Store/master/config/Surge-Noability.sgmodule)
35 |
36 |
37 | ### 3. QX
38 | 订阅 重写 [`https://raw.githubusercontent.com/sub-store-org/Sub-Store/master/config/QX.snippet`](https://raw.githubusercontent.com/sub-store-org/Sub-Store/master/config/QX.snippet) 即可。
39 |
40 | 定时任务: [`https://raw.githubusercontent.com/sub-store-org/Sub-Store/master/config/QX-Task.json`](https://raw.githubusercontent.com/sub-store-org/Sub-Store/master/config/QX-Task.json)
41 |
42 | ### 4. Stash
43 | 安装使用 覆写 [`https://raw.githubusercontent.com/sub-store-org/Sub-Store/master/config/Stash.stoverride`](https://raw.githubusercontent.com/sub-store-org/Sub-Store/master/config/Stash.stoverride) 即可。
44 |
45 | ### 5. Shadowrocket
46 | 安装使用 模块 [`https://raw.githubusercontent.com/sub-store-org/Sub-Store/master/config/Surge-Noability.sgmodule`](https://raw.githubusercontent.com/sub-store-org/Sub-Store/master/config/Surge-Noability.sgmodule) 即可。
47 |
48 | ### 6. Egern
49 | 安装使用 模块 [`https://raw.githubusercontent.com/sub-store-org/Sub-Store/master/config/Egern.yaml`](https://raw.githubusercontent.com/sub-store-org/Sub-Store/master/config/Egern.yaml) 即可。
50 |
51 | ## 使用 Sub-Store
52 | 1. 使用 Safari 打开这个 https://sub.store 如网页正常打开并且未弹出任何错误提示,说明 Sub-Store 已经配置成功。
53 | 2. 可以把 Sub-Store 添加到主屏幕,即可获得类似于 APP 的使用体验。
54 | 3. 更详细的使用指南请参考[文档](https://www.notion.so/Sub-Store-6259586994d34c11a4ced5c406264b46)。
55 |
56 | ## 链接参数说明
57 |
58 | https://github.com/sub-store-org/Sub-Store/wiki/%E9%93%BE%E6%8E%A5%E5%8F%82%E6%95%B0%E8%AF%B4%E6%98%8E
59 |
60 | ## 脚本使用说明
61 |
62 | https://github.com/sub-store-org/Sub-Store/wiki/%E8%84%9A%E6%9C%AC%E4%BD%BF%E7%94%A8%E8%AF%B4%E6%98%8E
63 |
--------------------------------------------------------------------------------
/config/Stash.stoverride:
--------------------------------------------------------------------------------
1 | name: Sub-Store
2 | desc: 高级订阅管理工具 @Peng-YM. 定时任务默认为每天 23 点 55 分. 定时任务指定时将订阅/文件上传到私有 Gist. 在前端, 叫做 '同步' 或 '同步配置'
3 | icon: https://raw.githubusercontent.com/cc63/ICON/main/Sub-Store.png
4 |
5 | http:
6 | mitm:
7 | - sub.store
8 | script:
9 | - match: ^https?:\/\/sub\.store\/((download)|api\/(preview|sync|(utils\/node-info)))
10 | name: sub-store-1
11 | type: request
12 | require-body: true
13 | timeout: 120
14 | - match: ^https?:\/\/sub\.store
15 | name: sub-store-0
16 | type: request
17 | require-body: true
18 | timeout: 120
19 |
20 | cron:
21 | script:
22 | - name: cron-sync-artifacts
23 | cron: "55 23 * * *"
24 | timeout: 120
25 |
26 | script-providers:
27 | sub-store-0:
28 | url: https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/sub-store-0.min.js
29 | interval: 86400
30 |
31 | sub-store-1:
32 | url: https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/sub-store-1.min.js
33 | interval: 86400
34 |
35 | cron-sync-artifacts:
36 | url: https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/cron-sync-artifacts.min.js
37 | interval: 86400
38 |
--------------------------------------------------------------------------------
/config/Surge-Beta.sgmodule:
--------------------------------------------------------------------------------
1 | #!name=Sub-Store(β)
2 | #!desc=支持 Surge 正式版的参数设置功能. 测落地功能 ability: http-client-policy, 同步配置的定时 cronexp: 55 23 * * *
3 | #!category=订阅管理
4 | #!arguments=ability:http-client-policy,cronexp:55 23 * * *,sync:"Sub-Store Sync",timeout:120,engine:auto,produce:"# Sub-Store Produce",produce_cronexp:50 */6 * * *,produce_sub:"sub1,sub2",produce_col:"col1,col2"
5 | #!arguments-desc=\n1️⃣ ability\n\n默认已开启测落地能力\n需要配合脚本操作\n如 https://raw.githubusercontent.com/Keywos/rule/main/cname.js\n填写任意其他值关闭\n\n2️⃣ cronexp\n\n同步配置定时任务\n默认为每天 23 点 55 分\n\n定时任务指定时将订阅/文件上传到私有 Gist. 在前端, 叫做 '同步' 或 '同步配置'\n\n3️⃣ sync\n\n自定义定时任务名\n便于在脚本编辑器中选择\n若设为 # 可取消定时任务\n\n4️⃣ timeout\n\n脚本超时, 单位为秒\n\n5️⃣ engine\n\n默认为自动使用 webview 引擎, 可设为指定 jsc, 但 jsc 容易爆内存\n\n6️⃣ produce\n\n自定义处理订阅的定时任务名\n一般用于定时处理耗时较长的订阅, 以更新缓存\n这样 Surge 中拉取的时候就能用到缓存, 不至于总是超时\n若设为 # 可取消此定时任务\n默认不开启\n\n7️⃣ produce_cronexp\n\n配置处理订阅的定时任务\n\n默认为每 6 小时\n\n9️⃣ produce_sub\n\n自定义需定时处理的单条订阅名\n多个用 , 连接\n\n🔟 produce_col\n\n自定义需定时处理的组合订阅名\n多个用 , 连接\n\n⚠️ 注意: 是 名称(name) 不是 显示名称(displayName)\n如果名称需要编码, 请编码后再用 , 连接\n顺序: 并发执行单条订阅, 然后并发执行组合订阅
6 |
7 | [MITM]
8 | hostname = %APPEND% sub.store
9 |
10 | [Script]
11 | Sub-Store Core=type=http-request,pattern=^https?:\/\/sub\.store\/((download)|api\/(preview|sync|(utils\/node-info))),script-path=https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/sub-store-1.min.js,requires-body=true,timeout={{{timeout}}},ability="{{{ability}}}",engine={{{engine}}}
12 |
13 | Sub-Store Simple=type=http-request,pattern=^https?:\/\/sub\.store,script-path=https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/sub-store-0.min.js,requires-body=true,timeout={{{timeout}}},engine={{{engine}}}
14 |
15 | {{{sync}}}=type=cron,cronexp="{{{cronexp}}}",wake-system=1,timeout={{{timeout}}},script-path=https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/cron-sync-artifacts.min.js,engine={{{engine}}}
16 |
17 | {{{produce}}}=type=cron,cronexp="{{{produce_cronexp}}}",wake-system=1,timeout={{{timeout}}},script-path=https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/cron-sync-artifacts.min.js,engine={{{engine}}},argument="sub={{{produce_sub}}}&col={{{produce_col}}}"
--------------------------------------------------------------------------------
/config/Surge-Noability.sgmodule:
--------------------------------------------------------------------------------
1 | #!name=Sub-Store
2 | #!desc=高级订阅管理工具 @Peng-YM 无 ability 参数版本,不会爆内存, 如果需要使用指定节点功能 例如[加旗帜脚本或者cname脚本] 可以用带 ability 参数. 定时任务默认为每天 23 点 55 分. 定时任务指定时将订阅/文件上传到私有 Gist. 在前端, 叫做 '同步' 或 '同步配置'
3 | #!category=订阅管理
4 |
5 | [MITM]
6 | hostname = %APPEND% sub.store
7 |
8 | [Script]
9 | # 主程序 已经去掉 Sub-Store Core 的参数 [,ability=http-client-policy] 不会爆内存,这个参数在 Surge 非常占用内存; 如果不需要使用指定节点功能 例如[加旗帜脚本或者cname脚本] 则可以使用此脚本
10 | Sub-Store Core=type=http-request,pattern=^https?:\/\/sub\.store\/((download)|api\/(preview|sync|(utils\/node-info))),script-path=https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/sub-store-1.min.js,requires-body=true,timeout=120
11 | Sub-Store Simple=type=http-request,pattern=^https?:\/\/sub\.store,script-path=https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/sub-store-0.min.js,requires-body=true,timeout=120
12 |
13 | Sub-Store Sync=type=cron,cronexp=55 23 * * *,wake-system=1,timeout=120,script-path=https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/cron-sync-artifacts.min.js
14 |
--------------------------------------------------------------------------------
/config/Surge-ability.sgmodule:
--------------------------------------------------------------------------------
1 | #!name=Sub-Store
2 | #!desc=高级订阅管理工具 @Peng-YM 带 ability 参数版本, 使用 jsc 引擎时, 可能会爆内存, 如果不需要使用指定节点功能 例如[加旗帜脚本或者cname脚本] 可以用不带 ability 参数版本. 定时任务默认为每天 23 点 55 分. 定时任务指定时将订阅/文件上传到私有 Gist. 在前端, 叫做 '同步' 或 '同步配置'
3 | #!category=订阅管理
4 |
5 | [MITM]
6 | hostname = %APPEND% sub.store
7 |
8 | [Script]
9 | Sub-Store Core=type=http-request,pattern=^https?:\/\/sub\.store\/((download)|api\/(preview|sync|(utils\/node-info))),script-path=https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/sub-store-1.min.js,requires-body=true,timeout=120,ability=http-client-policy
10 | Sub-Store Simple=type=http-request,pattern=^https?:\/\/sub\.store,script-path=https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/sub-store-0.min.js,requires-body=true,timeout=120
11 |
12 | Sub-Store Sync=type=cron,cronexp=55 23 * * *,wake-system=1,timeout=120,script-path=https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/cron-sync-artifacts.min.js
13 |
--------------------------------------------------------------------------------
/config/Surge.sgmodule:
--------------------------------------------------------------------------------
1 | #!name=Sub-Store
2 | #!desc=支持 Surge 正式版的参数设置功能. 测落地功能 ability: http-client-policy, 同步配置的定时 cronexp: 55 23 * * *
3 | #!category=订阅管理
4 | #!arguments=ability:http-client-policy,cronexp:55 23 * * *,sync:"Sub-Store Sync",timeout:120,engine:auto,produce:"# Sub-Store Produce",produce_cronexp:50 */6 * * *,produce_sub:"sub1,sub2",produce_col:"col1,col2"
5 | #!arguments-desc=\n1️⃣ ability\n\n默认已开启测落地能力\n需要配合脚本操作\n如 https://raw.githubusercontent.com/Keywos/rule/main/cname.js\n填写任意其他值关闭\n\n2️⃣ cronexp\n\n同步配置定时任务\n默认为每天 23 点 55 分\n\n定时任务指定时将订阅/文件上传到私有 Gist. 在前端, 叫做 '同步' 或 '同步配置'\n\n3️⃣ sync\n\n自定义定时任务名\n便于在脚本编辑器中选择\n若设为 # 可取消定时任务\n\n4️⃣ timeout\n\n脚本超时, 单位为秒\n\n5️⃣ engine\n\n默认为自动使用 webview 引擎, 可设为指定 jsc, 但 jsc 容易爆内存\n\n6️⃣ produce\n\n自定义处理订阅的定时任务名\n一般用于定时处理耗时较长的订阅, 以更新缓存\n这样 Surge 中拉取的时候就能用到缓存, 不至于总是超时\n若设为 # 可取消此定时任务\n默认不开启\n\n7️⃣ produce_cronexp\n\n配置处理订阅的定时任务\n\n默认为每 6 小时\n\n9️⃣ produce_sub\n\n自定义需定时处理的单条订阅名\n多个用 , 连接\n\n🔟 produce_col\n\n自定义需定时处理的组合订阅名\n多个用 , 连接\n\n⚠️ 注意: 是 名称(name) 不是 显示名称(displayName)\n如果名称需要编码, 请编码后再用 , 连接\n顺序: 并发执行单条订阅, 然后并发执行组合订阅
6 |
7 | [MITM]
8 | hostname = %APPEND% sub.store
9 |
10 | [Script]
11 | Sub-Store Core=type=http-request,pattern=^https?:\/\/sub\.store\/((download)|api\/(preview|sync|(utils\/node-info))),script-path=https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/sub-store-1.min.js,requires-body=true,timeout={{{timeout}}},ability="{{{ability}}}",engine={{{engine}}}
12 |
13 | Sub-Store Simple=type=http-request,pattern=^https?:\/\/sub\.store,script-path=https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/sub-store-0.min.js,requires-body=true,timeout={{{timeout}}},engine={{{engine}}}
14 |
15 | {{{sync}}}=type=cron,cronexp="{{{cronexp}}}",wake-system=1,timeout={{{timeout}}},script-path=https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/cron-sync-artifacts.min.js,engine={{{engine}}}
16 |
17 | {{{produce}}}=type=cron,cronexp="{{{produce_cronexp}}}",wake-system=1,timeout={{{timeout}}},script-path=https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/cron-sync-artifacts.min.js,engine={{{engine}}},argument="sub={{{produce_sub}}}&col={{{produce_col}}}"
--------------------------------------------------------------------------------
/nginx/front.conf:
--------------------------------------------------------------------------------
1 | upstream api {
2 | server 0.0.0.0:3000;
3 | }
4 |
5 | server {
6 | listen 6080;
7 | # allow 127.0.0.1;
8 | # allow 0.0.0.0;
9 | # deny all;
10 |
11 | gzip on;
12 | gzip_static on;
13 | gzip_types text/plain application/json application/javascript application/x-javascript text/css application/xml text/javascript;
14 | gzip_proxied any;
15 | gzip_vary on;
16 | gzip_comp_level 6;
17 | gzip_buffers 16 8k;
18 | gzip_http_version 1.0;
19 |
20 | location / {
21 | root /Sub-Store/web/dist;
22 | index index.html index.htm;
23 | try_files $uri $uri/ /index.html;
24 | }
25 |
26 | location /api {
27 | proxy_set_header Host $http_host;
28 | proxy_set_header X-Real-IP $remote_addr;
29 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
30 | proxy_pass http://api;
31 | }
32 |
33 | location /download {
34 | proxy_set_header Host $http_host;
35 | proxy_set_header X-Real-IP $remote_addr;
36 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
37 | proxy_pass http://api;
38 | }
39 |
40 | }
--------------------------------------------------------------------------------
/scripts/fancy-characters.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 节点名改为花里胡哨字体,仅支持英文字符和数字
3 | *
4 | * 【字体】
5 | * 可参考:https://www.dute.org/weird-fonts
6 | * serif-bold, serif-italic, serif-bold-italic, sans-serif-regular, sans-serif-bold-italic, script-regular, script-bold, fraktur-regular, fraktur-bold, monospace-regular, double-struck-bold, circle-regular, square-regular, modifier-letter(小写没有 q, 用 ᵠ 替代. 大写缺的太多, 用小写替代)
7 | *
8 | * 【示例】
9 | * 1️⃣ 设置所有格式为 "serif-bold"
10 | * #type=serif-bold
11 | *
12 | * 2️⃣ 设置字母格式为 "serif-bold",数字格式为 "circle-regular"
13 | * #type=serif-bold&num=circle-regular
14 | */
15 |
16 | function operator(proxies) {
17 | const { type, num } = $arguments;
18 | const TABLE = {
19 | "serif-bold": ["𝟎","𝟏","𝟐","𝟑","𝟒","𝟓","𝟔","𝟕","𝟖","𝟗","𝐚","𝐛","𝐜","𝐝","𝐞","𝐟","𝐠","𝐡","𝐢","𝐣","𝐤","𝐥","𝐦","𝐧","𝐨","𝐩","𝐪","𝐫","𝐬","𝐭","𝐮","𝐯","𝐰","𝐱","𝐲","𝐳","𝐀","𝐁","𝐂","𝐃","𝐄","𝐅","𝐆","𝐇","𝐈","𝐉","𝐊","𝐋","𝐌","𝐍","𝐎","𝐏","𝐐","𝐑","𝐒","𝐓","𝐔","𝐕","𝐖","𝐗","𝐘","𝐙"] ,
20 | "serif-italic": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "𝑎", "𝑏", "𝑐", "𝑑", "𝑒", "𝑓", "𝑔", "ℎ", "𝑖", "𝑗", "𝑘", "𝑙", "𝑚", "𝑛", "𝑜", "𝑝", "𝑞", "𝑟", "𝑠", "𝑡", "𝑢", "𝑣", "𝑤", "𝑥", "𝑦", "𝑧", "𝐴", "𝐵", "𝐶", "𝐷", "𝐸", "𝐹", "𝐺", "𝐻", "𝐼", "𝐽", "𝐾", "𝐿", "𝑀", "𝑁", "𝑂", "𝑃", "𝑄", "𝑅", "𝑆", "𝑇", "𝑈", "𝑉", "𝑊", "𝑋", "𝑌", "𝑍"],
21 | "serif-bold-italic": ["0","1","2","3","4","5","6","7","8","9","𝒂","𝒃","𝒄","𝒅","𝒆","𝒇","𝒈","𝒉","𝒊","𝒋","𝒌","𝒍","𝒎","𝒏","𝒐","𝒑","𝒒","𝒓","𝒔","𝒕","𝒖","𝒗","𝒘","𝒙","𝒚","𝒛","𝑨","𝑩","𝑪","𝑫","𝑬","𝑭","𝑮","𝑯","𝑰","𝑱","𝑲","𝑳","𝑴","𝑵","𝑶","𝑷","𝑸","𝑹","𝑺","𝑻","𝑼","𝑽","𝑾","𝑿","𝒀","𝒁"],
22 | "sans-serif-regular": ["𝟢", "𝟣", "𝟤", "𝟥", "𝟦", "𝟧", "𝟨", "𝟩", "𝟪", "𝟫", "𝖺", "𝖻", "𝖼", "𝖽", "𝖾", "𝖿", "𝗀", "𝗁", "𝗂", "𝗃", "𝗄", "𝗅", "𝗆", "𝗇", "𝗈", "𝗉", "𝗊", "𝗋", "𝗌", "𝗍", "𝗎", "𝗏", "𝗐", "𝗑", "𝗒", "𝗓", "𝖠", "𝖡", "𝖢", "𝖣", "𝖤", "𝖥", "𝖦", "𝖧", "𝖨", "𝖩", "𝖪", "𝖫", "𝖬", "𝖭", "𝖮", "𝖯", "𝖰", "𝖱", "𝖲", "𝖳", "𝖴", "𝖵", "𝖶", "𝖷", "𝖸", "𝖹"],
23 | "sans-serif-bold": ["𝟬","𝟭","𝟮","𝟯","𝟰","𝟱","𝟲","𝟳","𝟴","𝟵","𝗮","𝗯","𝗰","𝗱","𝗲","𝗳","𝗴","𝗵","𝗶","𝗷","𝗸","𝗹","𝗺","𝗻","𝗼","𝗽","𝗾","𝗿","𝘀","𝘁","𝘂","𝘃","𝘄","𝘅","𝘆","𝘇","𝗔","𝗕","𝗖","𝗗","𝗘","𝗙","𝗚","𝗛","𝗜","𝗝","𝗞","𝗟","𝗠","𝗡","𝗢","𝗣","𝗤","𝗥","𝗦","𝗧","𝗨","𝗩","𝗪","𝗫","𝗬","𝗭"],
24 | "sans-serif-italic": ["0","1","2","3","4","5","6","7","8","9","𝘢","𝘣","𝘤","𝘥","𝘦","𝘧","𝘨","𝘩","𝘪","𝘫","𝘬","𝘭","𝘮","𝘯","𝘰","𝘱","𝘲","𝘳","𝘴","𝘵","𝘶","𝘷","𝘸","𝘹","𝘺","𝘻","𝘈","𝘉","𝘊","𝘋","𝘌","𝘍","𝘎","𝘏","𝘐","𝘑","𝘒","𝘓","𝘔","𝘕","𝘖","𝘗","𝘘","𝘙","𝘚","𝘛","𝘜","𝘝","𝘞","𝘟","𝘠","𝘡"],
25 | "sans-serif-bold-italic": ["0","1","2","3","4","5","6","7","8","9","𝙖","𝙗","𝙘","𝙙","𝙚","𝙛","𝙜","𝙝","𝙞","𝙟","𝙠","𝙡","𝙢","𝙣","𝙤","𝙥","𝙦","𝙧","𝙨","𝙩","𝙪","𝙫","𝙬","𝙭","𝙮","𝙯","𝘼","𝘽","𝘾","𝘿","𝙀","𝙁","𝙂","𝙃","𝙄","𝙅","𝙆","𝙇","𝙈","𝙉","𝙊","𝙋","𝙌","𝙍","𝙎","𝙏","𝙐","𝙑","𝙒","𝙓","𝙔","𝙕"],
26 | "script-regular": ["0","1","2","3","4","5","6","7","8","9","𝒶","𝒷","𝒸","𝒹","ℯ","𝒻","ℊ","𝒽","𝒾","𝒿","𝓀","𝓁","𝓂","𝓃","ℴ","𝓅","𝓆","𝓇","𝓈","𝓉","𝓊","𝓋","𝓌","𝓍","𝓎","𝓏","𝒜","ℬ","𝒞","𝒟","ℰ","ℱ","𝒢","ℋ","ℐ","𝒥","𝒦","ℒ","ℳ","𝒩","𝒪","𝒫","𝒬","ℛ","𝒮","𝒯","𝒰","𝒱","𝒲","𝒳","𝒴","𝒵"],
27 | "script-bold": ["0","1","2","3","4","5","6","7","8","9","𝓪","𝓫","𝓬","𝓭","𝓮","𝓯","𝓰","𝓱","𝓲","𝓳","𝓴","𝓵","𝓶","𝓷","𝓸","𝓹","𝓺","𝓻","𝓼","𝓽","𝓾","𝓿","𝔀","𝔁","𝔂","𝔃","𝓐","𝓑","𝓒","𝓓","𝓔","𝓕","𝓖","𝓗","𝓘","𝓙","𝓚","𝓛","𝓜","𝓝","𝓞","𝓟","𝓠","𝓡","𝓢","𝓣","𝓤","𝓥","𝓦","𝓧","𝓨","𝓩"],
28 | "fraktur-regular": ["0","1","2","3","4","5","6","7","8","9","𝔞","𝔟","𝔠","𝔡","𝔢","𝔣","𝔤","𝔥","𝔦","𝔧","𝔨","𝔩","𝔪","𝔫","𝔬","𝔭","𝔮","𝔯","𝔰","𝔱","𝔲","𝔳","𝔴","𝔵","𝔶","𝔷","𝔄","𝔅","ℭ","𝔇","𝔈","𝔉","𝔊","ℌ","ℑ","𝔍","𝔎","𝔏","𝔐","𝔑","𝔒","𝔓","𝔔","ℜ","𝔖","𝔗","𝔘","𝔙","𝔚","𝔛","𝔜","ℨ"],
29 | "fraktur-bold": ["0","1","2","3","4","5","6","7","8","9","𝖆","𝖇","𝖈","𝖉","𝖊","𝖋","𝖌","𝖍","𝖎","𝖏","𝖐","𝖑","𝖒","𝖓","𝖔","𝖕","𝖖","𝖗","𝖘","𝖙","𝖚","𝖛","𝖜","𝖝","𝖞","𝖟","𝕬","𝕭","𝕮","𝕯","𝕰","𝕱","𝕲","𝕳","𝕴","𝕵","𝕶","𝕷","𝕸","𝕹","𝕺","𝕻","𝕼","𝕽","𝕾","𝕿","𝖀","𝖁","𝖂","𝖃","𝖄","𝖅"],
30 | "monospace-regular": ["𝟶","𝟷","𝟸","𝟹","𝟺","𝟻","𝟼","𝟽","𝟾","𝟿","𝚊","𝚋","𝚌","𝚍","𝚎","𝚏","𝚐","𝚑","𝚒","𝚓","𝚔","𝚕","𝚖","𝚗","𝚘","𝚙","𝚚","𝚛","𝚜","𝚝","𝚞","𝚟","𝚠","𝚡","𝚢","𝚣","𝙰","𝙱","𝙲","𝙳","𝙴","𝙵","𝙶","𝙷","𝙸","𝙹","𝙺","𝙻","𝙼","𝙽","𝙾","𝙿","𝚀","𝚁","𝚂","𝚃","𝚄","𝚅","𝚆","𝚇","𝚈","𝚉"],
31 | "double-struck-bold": ["𝟘","𝟙","𝟚","𝟛","𝟜","𝟝","𝟞","𝟟","𝟠","𝟡","𝕒","𝕓","𝕔","𝕕","𝕖","𝕗","𝕘","𝕙","𝕚","𝕛","𝕜","𝕝","𝕞","𝕟","𝕠","𝕡","𝕢","𝕣","𝕤","𝕥","𝕦","𝕧","𝕨","𝕩","𝕪","𝕫","𝔸","𝔹","ℂ","𝔻","𝔼","𝔽","𝔾","ℍ","𝕀","𝕁","𝕂","𝕃","𝕄","ℕ","𝕆","ℙ","ℚ","ℝ","𝕊","𝕋","𝕌","𝕍","𝕎","𝕏","𝕐","ℤ"],
32 | "circle-regular": ["⓪","①","②","③","④","⑤","⑥","⑦","⑧","⑨","ⓐ","ⓑ","ⓒ","ⓓ","ⓔ","ⓕ","ⓖ","ⓗ","ⓘ","ⓙ","ⓚ","ⓛ","ⓜ","ⓝ","ⓞ","ⓟ","ⓠ","ⓡ","ⓢ","ⓣ","ⓤ","ⓥ","ⓦ","ⓧ","ⓨ","ⓩ","Ⓐ","Ⓑ","Ⓒ","Ⓓ","Ⓔ","Ⓕ","Ⓖ","Ⓗ","Ⓘ","Ⓙ","Ⓚ","Ⓛ","Ⓜ","Ⓝ","Ⓞ","Ⓟ","Ⓠ","Ⓡ","Ⓢ","Ⓣ","Ⓤ","Ⓥ","Ⓦ","Ⓧ","Ⓨ","Ⓩ"],
33 | "square-regular": ["0","1","2","3","4","5","6","7","8","9","🄰","🄱","🄲","🄳","🄴","🄵","🄶","🄷","🄸","🄹","🄺","🄻","🄼","🄽","🄾","🄿","🅀","🅁","🅂","🅃","🅄","🅅","🅆","🅇","🅈","🅉","🄰","🄱","🄲","🄳","🄴","🄵","🄶","🄷","🄸","🄹","🄺","🄻","🄼","🄽","🄾","🄿","🅀","🅁","🅂","🅃","🅄","🅅","🅆","🅇","🅈","🅉"],
34 | "modifier-letter": ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹", "ᵃ", "ᵇ", "ᶜ", "ᵈ", "ᵉ", "ᶠ", "ᵍ", "ʰ", "ⁱ", "ʲ", "ᵏ", "ˡ", "ᵐ", "ⁿ", "ᵒ", "ᵖ", "ᵠ", "ʳ", "ˢ", "ᵗ", "ᵘ", "ᵛ", "ʷ", "ˣ", "ʸ", "ᶻ", "ᴬ", "ᴮ", "ᶜ", "ᴰ", "ᴱ", "ᶠ", "ᴳ", "ʰ", "ᴵ", "ᴶ", "ᴷ", "ᴸ", "ᴹ", "ᴺ", "ᴼ", "ᴾ", "ᵠ", "ᴿ", "ˢ", "ᵀ", "ᵁ", "ᵛ", "ᵂ", "ˣ", "ʸ", "ᶻ"],
35 | };
36 |
37 | // charCode => index in `TABLE`
38 | const INDEX = { "48": 0, "49": 1, "50": 2, "51": 3, "52": 4, "53": 5, "54": 6, "55": 7, "56": 8, "57": 9, "65": 36, "66": 37, "67": 38, "68": 39, "69": 40, "70": 41, "71": 42, "72": 43, "73": 44, "74": 45, "75": 46, "76": 47, "77": 48, "78": 49, "79": 50, "80": 51, "81": 52, "82": 53, "83": 54, "84": 55, "85": 56, "86": 57, "87": 58, "88": 59, "89": 60, "90": 61, "97": 10, "98": 11, "99": 12, "100": 13, "101": 14, "102": 15, "103": 16, "104": 17, "105": 18, "106": 19, "107": 20, "108": 21, "109": 22, "110": 23, "111": 24, "112": 25, "113": 26, "114": 27, "115": 28, "116": 29, "117": 30, "118": 31, "119": 32, "120": 33, "121": 34, "122": 35 };
39 |
40 | return proxies.map(p => {
41 | p.name = [...p.name].map(c => {
42 | if (/[a-zA-Z0-9]/.test(c)) {
43 | const code = c.charCodeAt(0);
44 | const index = INDEX[code];
45 | if (isNumber(code) && num) {
46 | return TABLE[num][index];
47 | } else {
48 | return TABLE[type][index];
49 | }
50 | }
51 | return c;
52 | }).join("");
53 | return p;
54 | })
55 | }
56 |
57 | function isNumber(code) { return code >= 48 && code <= 57; }
--------------------------------------------------------------------------------
/scripts/ip-flag-node.js:
--------------------------------------------------------------------------------
1 | const $ = $substore;
2 |
3 | const {onlyFlagIP = true} = $arguments
4 |
5 | async function operator(proxies) {
6 | const BATCH_SIZE = 10;
7 |
8 | let i = 0;
9 | while (i < proxies.length) {
10 | const batch = proxies.slice(i, i + BATCH_SIZE);
11 | await Promise.all(batch.map(async proxy => {
12 | if (onlyFlagIP && !ProxyUtils.isIP(proxy.server)) return;
13 | try {
14 | // remove the original flag
15 | let proxyName = removeFlag(proxy.name);
16 |
17 | // query ip-api
18 | const countryCode = await queryIpApi(proxy);
19 |
20 | proxyName = getFlagEmoji(countryCode) + ' ' + proxyName;
21 | proxy.name = proxyName;
22 | } catch (err) {
23 | // TODO:
24 | }
25 | }));
26 |
27 | await sleep(1000);
28 | i += BATCH_SIZE;
29 | }
30 | return proxies;
31 | }
32 |
33 |
34 | async function queryIpApi(proxy) {
35 | const ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:78.0) Gecko/20100101 Firefox/78.0";
36 | const headers = {
37 | "User-Agent": ua
38 | };
39 | const result = new Promise((resolve, reject) => {
40 | const url =
41 | `http://ip-api.com/json/${encodeURIComponent(proxy.server)}?lang=zh-CN`;
42 | $.http.get({
43 | url,
44 | headers,
45 | }).then(resp => {
46 | const data = JSON.parse(resp.body);
47 | if (data.status === "success") {
48 | resolve(data.countryCode);
49 | } else {
50 | reject(new Error(data.message));
51 | }
52 | }).catch(err => {
53 | console.log(err);
54 | reject(err);
55 | });
56 | });
57 | return result;
58 | }
59 |
60 | function getFlagEmoji(countryCode) {
61 | const codePoints = countryCode
62 | .toUpperCase()
63 | .split('')
64 | .map(char => 127397 + char.charCodeAt());
65 | return String
66 | .fromCodePoint(...codePoints)
67 | .replace(/🇹🇼/g, '🇨🇳');
68 | }
69 |
70 | function removeFlag(str) {
71 | return str
72 | .replace(/[\uD83C][\uDDE6-\uDDFF][\uD83C][\uDDE6-\uDDFF]/g, '')
73 | .trim();
74 | }
75 |
76 | function sleep(ms) {
77 | return new Promise((resolve) => setTimeout(resolve, ms));
78 | }
79 |
80 |
--------------------------------------------------------------------------------
/scripts/media-filter.js:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sub-store-org/Sub-Store/4aafdaaddbc7ba24651501eaaa25da69219084d9/scripts/media-filter.js
--------------------------------------------------------------------------------
/scripts/revert.js:
--------------------------------------------------------------------------------
1 | const $ = API()
2 | $.write("{}", "#sub-store")
3 | $.done()
4 |
5 | function ENV(){const e="function"==typeof require&&"undefined"!=typeof $jsbox;return{isQX:"undefined"!=typeof $task,isLoon:"undefined"!=typeof $loon,isSurge:"undefined"!=typeof $httpClient&&"undefined"!=typeof $utils,isBrowser:"undefined"!=typeof document,isNode:"function"==typeof require&&!e,isJSBox:e,isRequest:"undefined"!=typeof $request,isScriptable:"undefined"!=typeof importModule}}function HTTP(e={baseURL:""}){const{isQX:t,isLoon:s,isSurge:o,isScriptable:n,isNode:i,isBrowser:r}=ENV(),u=/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/;const a={};return["GET","POST","PUT","DELETE","HEAD","OPTIONS","PATCH"].forEach(h=>a[h.toLowerCase()]=(a=>(function(a,h){h="string"==typeof h?{url:h}:h;const d=e.baseURL;d&&!u.test(h.url||"")&&(h.url=d?d+h.url:h.url),h.body&&h.headers&&!h.headers["Content-Type"]&&(h.headers["Content-Type"]="application/x-www-form-urlencoded");const l=(h={...e,...h}).timeout,c={onRequest:()=>{},onResponse:e=>e,onTimeout:()=>{},...h.events};let f,p;if(c.onRequest(a,h),t)f=$task.fetch({method:a,...h});else if(s||o||i)f=new Promise((e,t)=>{(i?require("request"):$httpClient)[a.toLowerCase()](h,(s,o,n)=>{s?t(s):e({statusCode:o.status||o.statusCode,headers:o.headers,body:n})})});else if(n){const e=new Request(h.url);e.method=a,e.headers=h.headers,e.body=h.body,f=new Promise((t,s)=>{e.loadString().then(s=>{t({statusCode:e.response.statusCode,headers:e.response.headers,body:s})}).catch(e=>s(e))})}else r&&(f=new Promise((e,t)=>{fetch(h.url,{method:a,headers:h.headers,body:h.body}).then(e=>e.json()).then(t=>e({statusCode:t.status,headers:t.headers,body:t.data})).catch(t)}));const y=l?new Promise((e,t)=>{p=setTimeout(()=>(c.onTimeout(),t(`${a} URL: ${h.url} exceeds the timeout ${l} ms`)),l)}):null;return(y?Promise.race([y,f]).then(e=>(clearTimeout(p),e)):f).then(e=>c.onResponse(e))})(h,a))),a}function API(e="untitled",t=!1){const{isQX:s,isLoon:o,isSurge:n,isNode:i,isJSBox:r,isScriptable:u}=ENV();return new class{constructor(e,t){this.name=e,this.debug=t,this.http=HTTP(),this.env=ENV(),this.node=(()=>{if(i){return{fs:require("fs")}}return null})(),this.initCache();Promise.prototype.delay=function(e){return this.then(function(t){return((e,t)=>new Promise(function(s){setTimeout(s.bind(null,t),e)}))(e,t)})}}initCache(){if(s&&(this.cache=JSON.parse($prefs.valueForKey(this.name)||"{}")),(o||n)&&(this.cache=JSON.parse($persistentStore.read(this.name)||"{}")),i){let e="root.json";this.node.fs.existsSync(e)||this.node.fs.writeFileSync(e,JSON.stringify({}),{flag:"wx"},e=>console.log(e)),this.root={},e=`${this.name}.json`,this.node.fs.existsSync(e)?this.cache=JSON.parse(this.node.fs.readFileSync(`${this.name}.json`)):(this.node.fs.writeFileSync(e,JSON.stringify({}),{flag:"wx"},e=>console.log(e)),this.cache={})}}persistCache(){const e=JSON.stringify(this.cache,null,2);s&&$prefs.setValueForKey(e,this.name),(o||n)&&$persistentStore.write(e,this.name),i&&(this.node.fs.writeFileSync(`${this.name}.json`,e,{flag:"w"},e=>console.log(e)),this.node.fs.writeFileSync("root.json",JSON.stringify(this.root,null,2),{flag:"w"},e=>console.log(e)))}write(e,t){if(this.log(`SET ${t}`),-1!==t.indexOf("#")){if(t=t.substr(1),n||o)return $persistentStore.write(e,t);if(s)return $prefs.setValueForKey(e,t);i&&(this.root[t]=e)}else this.cache[t]=e;this.persistCache()}read(e){return this.log(`READ ${e}`),-1===e.indexOf("#")?this.cache[e]:(e=e.substr(1),n||o?$persistentStore.read(e):s?$prefs.valueForKey(e):i?this.root[e]:void 0)}delete(e){if(this.log(`DELETE ${e}`),-1!==e.indexOf("#")){if(e=e.substr(1),n||o)return $persistentStore.write(null,e);if(s)return $prefs.removeValueForKey(e);i&&delete this.root[e]}else delete this.cache[e];this.persistCache()}notify(e,t="",a="",h={}){const d=h["open-url"],l=h["media-url"];if(s&&$notify(e,t,a,h),n&&$notification.post(e,t,a+`${l?"\n多媒体:"+l:""}`,{url:d}),o){let s={};d&&(s.openUrl=d),l&&(s.mediaUrl=l),"{}"===JSON.stringify(s)?$notification.post(e,t,a):$notification.post(e,t,a,s)}if(i||u){const s=a+(d?`\n点击跳转: ${d}`:"")+(l?`\n多媒体: ${l}`:"");if(r){require("push").schedule({title:e,body:(t?t+"\n":"")+s})}else console.log(`${e}\n${t}\n${s}\n\n`)}}log(e){this.debug&&console.log(`[${this.name}] LOG: ${this.stringify(e)}`)}info(e){console.log(`[${this.name}] INFO: ${this.stringify(e)}`)}error(e){console.log(`[${this.name}] ERROR: ${this.stringify(e)}`)}wait(e){return new Promise(t=>setTimeout(t,e))}done(e={}){s||o||n?$done(e):i&&!r&&"undefined"!=typeof $context&&($context.headers=e.headers,$context.statusCode=e.statusCode,$context.body=e.body)}stringify(e){if("string"==typeof e||e instanceof String)return e;try{return JSON.stringify(e,null,2)}catch(e){return"[object Object]"}}}(e,t)}
6 |
--------------------------------------------------------------------------------
/scripts/tls-fingerprint.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 为节点添加 tls 证书指纹
3 | * 示例
4 | * #fingerprint=...
5 | */
6 | function operator(proxies) {
7 | const { fingerprint } = $arguments;
8 | proxies.forEach(proxy => {
9 | proxy['tls-fingerprint'] = fingerprint;
10 | });
11 | return proxies;
12 | }
--------------------------------------------------------------------------------
/scripts/udp-filter.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 过滤 UDP 节点
3 | */
4 | function filter(proxies) {
5 | return proxies.map(p => p.udp);
6 | }
7 |
--------------------------------------------------------------------------------
/scripts/vmess-ws-obfs-host.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 为 VMess WebSocket 节点修改混淆 host
3 | * 示例
4 | * #host=google.com
5 | */
6 | function operator(proxies) {
7 | const { host } = $arguments;
8 | proxies.forEach(p => {
9 | if (p.type === 'vmess' && p.network === 'ws') {
10 | p["ws-opts"] = p["ws-opts"] || {};
11 | p["ws-opts"]["headers"] = p["ws-opts"]["headers"] || {};
12 | p["ws-opts"]["headers"]["Host"] = host;
13 | }
14 | });
15 | return proxies;
16 | }
--------------------------------------------------------------------------------
/support.nodeseek.com_page_promotion_id=8.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sub-store-org/Sub-Store/4aafdaaddbc7ba24651501eaaa25da69219084d9/support.nodeseek.com_page_promotion_id=8.png
--------------------------------------------------------------------------------
/vs.code-workspace:
--------------------------------------------------------------------------------
1 | {
2 | "folders": [
3 | {
4 | "path": "."
5 | }
6 | ],
7 | "settings": {}
8 | }
--------------------------------------------------------------------------------