├── .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 | Sub-Store 4 |
5 |
6 |

Sub-Store

7 |

8 | 9 |

10 | Advanced Subscription Manager for QX, Loon, Surge, Stash, Egern and Shadowrocket. 11 |

12 | 13 | [![Build](https://github.com/sub-store-org/Sub-Store/actions/workflows/main.yml/badge.svg)](https://github.com/sub-store-org/Sub-Store/actions/workflows/main.yml) ![GitHub](https://img.shields.io/github/license/sub-store-org/Sub-Store) ![GitHub issues](https://img.shields.io/github/issues/sub-store-org/Sub-Store) ![GitHub closed pull requests](https://img.shields.io/github/issues-pr-closed-raw/Peng-Ym/Sub-Store) ![Lines of code](https://img.shields.io/tokei/lines/github/sub-store-org/Sub-Store) ![Size](https://img.shields.io/github/languages/code-size/sub-store-org/Sub-Store) 14 | sub-store-org%2FSub-Store | Trendshift 15 | [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](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 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FPeng-YM%2FSub-Store.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2FPeng-YM%2FSub-Store?ref=badge_large) 122 | 123 | ## Star History 124 | 125 | [![Star History Chart](https://api.star-history.com/svg?repos=sub-store-org/sub-store&type=Date)](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 | [![image](./support.nodeseek.com_page_promotion_id=8.png)](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 | } --------------------------------------------------------------------------------