├── .gitignore ├── README.md ├── package.json ├── public ├── favicon.ico ├── img │ └── logo │ │ ├── ss_logo.png │ │ ├── trojan-gfw_logo.png │ │ └── v2ray_logo.png ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.css ├── App.js ├── App.test.js ├── img.js ├── index.css ├── index.js ├── serviceWorker.js ├── setupTests.js └── submit.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BASE64格式訂閱連結編輯器 (小火箭訂閱鏈接編輯器) 2 | 一個可以生成和修改ShadowRocket (iOS)、v2rayNG (Android)訂閱連結的工具。 3 | 4 | ps. [後端 API](https://github.com/phlinhng/b64-url-editor-backend) 服務器爆了,不再維護訂閱功能。 5 | 6 | ## 網址 7 | 8 | 9 | ## 支持的協議 10 | + [v2Ray](https://www.v2ray.com/index.html) (`vmess://...`):支持TCP、WS、KCP三種連接方式 11 | + [Trojan-GFW](https://github.com/trojan-gfw/trojan) (`trojan://...`) 12 | + [Shadowsocks](https://en.wikipedia.org/wiki/Shadowsocks) (`ss://...`):支持所有加密方式 13 | 14 | ## 功能 15 | 1. 導入格式:訂閱連結、節點列表、BASE64文本 16 | 2. 修改節點名稱、服務器地址、加密方式(ss)、密碼(ss,trojan)、連接方式(v2ray)、TLS(v2ray)、WS域名與路徑(v2ray)等 17 | 3. 新增、刪除節點 18 | 4. 合併節點列表 19 | 5. **生成二維碼** 20 | 6. **生成訂閱鏈接** 21 | 7. 當BASE64轉換器用 22 | 23 | ## 用法 24 | ### 手動導入訂閱鏈接 25 | 在API欄位中輸入訂閱鏈接,目前只支持導入一個 26 | ### 手動添加節點列表 27 | 在API或TEXT欄位中輸入節點網址列表,支持添加多個節點,不同節點間使用換行(`\n`)、逗號(`,`)或分號(`;`)隔開。 28 | ### 手動添加BASE64密文 29 | 在BASE64欄位中輸入BASE64文本。 30 | ### URL Query導入訂閱鏈接 31 | ``` 32 | https://www.phlinhng.com/b64-url-editor?sub=[your subscrption links] 33 | ``` 34 | 設置`qrcode=yes`可以自動顯示二維碼 35 | ``` 36 | https://www.phlinhng.com/b64-url-editor?sub=[your subscrption links]&qrcode=yes 37 | ``` 38 | ### 生成訂閱鏈接 39 | 1. 按`訂閱鏈接`,會出現生成確認,點擊確認繼續生成步驟 40 | 2. 輸入ID和密碼,之後每次輸入相同的ID與密碼組合可以更新之前的鏈接內容 41 | 42 | ### 生成訂閱鏈接注意事項 43 | 1. ID和密碼不需認證,只要你覺得好記就行,亂打也是可以的。 44 | 2. 目前沒有設計密碼找回機制,若忘記密碼請直接換一組新的。 45 | 3. ID可以重複,相同ID不同密碼會生成不同鏈接。 46 | 47 | ### 單純BASE64轉換 48 | + **文字轉BASE64 (encoding)**:URL欄位輸入任意文字,切換至BASE64欄位即可看到轉換結果。 49 | + **BASE64轉文字 (decoding)**:BASE64欄位輸入密文,切換至URL欄位即可看到轉換結果。 50 | 51 | ## known issues 52 | + clipboard content only loaded once, need to add a listener for clipboard changes 53 | + adding a new shadowsocks profile may change the height of `operateTab` 54 | 55 | ## todo 56 | + [x] 新增節點 57 | + [x] 生成QR Code 58 | + [x] 生成訂閱鏈接 (需要api與服務器) 59 | + [x] 完善URL Query 60 | + [ ] 完善一鍵粘貼 61 | + [ ] 調換節點順序 62 | + [x] 多個訂閱連結合併 63 | + [x] 完成README.md 64 | + [x] 寫一篇博客文章 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "b64-url-editor", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "https://www.phlinhng.com/b64-url-editor", 6 | "dependencies": { 7 | "@testing-library/jest-dom": "^4.2.4", 8 | "@testing-library/react": "^9.3.2", 9 | "@testing-library/user-event": "^7.1.2", 10 | "antd": "^4.0.0", 11 | "axios": "^0.19.2", 12 | "crypto": "^1.0.1", 13 | "gh-pages": "^2.2.0", 14 | "github-fork-ribbon-css": "^0.2.3", 15 | "qrcode.react": "^1.0.0", 16 | "react": "^16.13.0", 17 | "react-dom": "^16.13.0", 18 | "react-scripts": "3.4.0", 19 | "shortid": "^2.2.15", 20 | "use-clippy": "^1.0.6" 21 | }, 22 | "scripts": { 23 | "predeploy": "yarn run build", 24 | "deploy": "gh-pages -d build", 25 | "start": "PORT=3001 react-scripts start", 26 | "build": "react-scripts build", 27 | "test": "react-scripts test", 28 | "eject": "react-scripts eject" 29 | }, 30 | "eslintConfig": { 31 | "extends": "react-app" 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phlinhng/b64-url-editor/ce1ae5a88e1cefb67964649a54e9b8c7349e23b4/public/favicon.ico -------------------------------------------------------------------------------- /public/img/logo/ss_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phlinhng/b64-url-editor/ce1ae5a88e1cefb67964649a54e9b8c7349e23b4/public/img/logo/ss_logo.png -------------------------------------------------------------------------------- /public/img/logo/trojan-gfw_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phlinhng/b64-url-editor/ce1ae5a88e1cefb67964649a54e9b8c7349e23b4/public/img/logo/trojan-gfw_logo.png -------------------------------------------------------------------------------- /public/img/logo/v2ray_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phlinhng/b64-url-editor/ce1ae5a88e1cefb67964649a54e9b8c7349e23b4/public/img/logo/v2ray_logo.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | 小火箭訂閱編輯器 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phlinhng/b64-url-editor/ce1ae5a88e1cefb67964649a54e9b8c7349e23b4/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phlinhng/b64-url-editor/ce1ae5a88e1cefb67964649a54e9b8c7349e23b4/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Shawdowrockets訂閱鏈接編輯器", 3 | "name": "Subscribe link editor for shadowrocket and v2RayNG", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | @import '~antd/dist/antd.css'; 2 | 3 | body { 4 | margin: 0; 5 | padding: 0; 6 | 7 | /** background **/ 8 | background-color: #f0f2f5; 9 | background-attachment: fixed; 10 | background-size: cover; 11 | } 12 | 13 | .App { 14 | text-align: center; 15 | padding: 8px 16px 0 16px; 16 | } 17 | 18 | .App-logo { 19 | height: 40vmin; 20 | pointer-events: none; 21 | } 22 | 23 | .App-link { 24 | color: #61dafb; 25 | } 26 | 27 | .logo-wrap { 28 | height: 1.2em; 29 | vertical-align: sub; 30 | } 31 | 32 | .card { 33 | height: 100%; 34 | } 35 | 36 | .github-fork-ribbon.right-bottom:before { 37 | background-color: #090; 38 | } 39 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Button, Input, Menu, Radio, Select, Switch, Dropdown } from 'antd'; 3 | import { Layout, Row, Col, Card } from 'antd'; 4 | import { Badge, Modal, message, Skeleton } from 'antd'; 5 | import { CheckOutlined, DeleteOutlined, InfoOutlined, PlusOutlined, QrcodeOutlined } from '@ant-design/icons'; 6 | import { ExclamationCircleOutlined, QuestionCircleTwoTone, ShareAltOutlined } from '@ant-design/icons'; 7 | import shortid from 'shortid'; 8 | import useClippy from 'use-clippy'; 9 | import axios from 'axios'; 10 | import crypto from 'crypto'; 11 | import QRCode from 'qrcode.react'; 12 | import './App.css'; 13 | import { Logo } from './img'; 14 | import { apiBaseURL } from './submit'; 15 | import 'github-fork-ribbon-css/gh-fork-ribbon.css'; 16 | 17 | const { TextArea } = Input; 18 | const { Option } = Select; 19 | const { Content, Footer } = Layout; 20 | const { confirm } = Modal; 21 | const InputGroup = Input.Group; 22 | 23 | const supportedType = ['ss','vmess','trojan']; 24 | const validPrefix = /^(vmess|ss|trojan).*/; 25 | 26 | const base64regex = /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/; 27 | 28 | const inputTabList = [{key: 'API', tab: 'API'}, {key: 'TEXT', tab: 'URL'}, {key: 'BASE64', tab: 'BASE64'} ]; 29 | 30 | const operateTabList = [{key: 'fastEdit', tab: '快速操作'}, {key: 'detailedEdit', tab: '詳細編輯'}]; 31 | 32 | const ssMethod = ['none','table','rc4','rc4-md5','rc4-md5-6','salsa20','chacha20','chacha20-ietf', 33 | 'aes-256-cfb','aes-192-cfb','aes-128-cfb','aes-256-cfb1','aes-192-cfb1','aes-128-cfb1','aes-256-cfb8','aes-192-cfb8','aes-128-cfb8', 34 | 'aes-256-ctr','aes-192-ctr','aes-128-ctr','bf-cfb','camellia-256-cfb','camellia-192-cfb','camellia-128-cfb', 35 | 'cast5-cfb','des-cfb','idea-cfb','seed-cfb','aes-256-gcm','aes-192-gcm','aes-128-gcm', 36 | 'chacha20-ietf-poly1305','chacha20-poly1305','xchacha20-ietf-poly1305']; 37 | 38 | const defaultJson = { 39 | ss: (num) => ({ id: "加密方式 (Method)", aid: "", add: "", port: "", ps: "new shadowsocks [%]".replace('%',num) }), 40 | vmess: (num) => ({ add: "", port:"", id:"", aid: 0, net: "", host: "", path:"/", tls: "none", type: "none", ps: "new v2ray [%]".replace('%',num), v: 2 }), 41 | trojan: (num) => ({ aid: "", add: "", port: "", ps: "new trojan [%]".replace('%',num) }) 42 | } 43 | 44 | // convert utf-8 encoded base64 45 | const Base64 = { 46 | encode: (s) => { 47 | return btoa(unescape(encodeURIComponent(s))); 48 | }, 49 | decode: (s) => { 50 | return decodeURIComponent(escape(atob(s))); 51 | } 52 | }; 53 | 54 | const urlType = (text) => { 55 | return validPrefix.test(text)? text.slice(0,text.search('://')):'unsupported'; 56 | } 57 | 58 | const textTool = { 59 | text2json: ({ 60 | vmess: ( (text) => { 61 | const vmess_json = JSON.parse(Base64.decode(text.replace('vmess://',''))); 62 | return { type: urlType(text), json: vmess_json, raw: text, id: shortid.generate() }; 63 | }), 64 | ss: ( (text) => { 65 | const remarkStartIndex = text.search('#'); 66 | const ss_name = decodeURIComponent(text.slice(remarkStartIndex+1)); 67 | const ss_link = text.slice(5,remarkStartIndex).split(/[@:]+/); // userinfo@mydomain.com:8888 68 | const ss_info = Base64.decode(ss_link[0]).split(':'); // userinfo = websafe-base64-encode-utf8(method ":" password) 69 | const ss_json = { id: ss_info[0], aid: ss_info[1], add: ss_link[1], port: ss_link[2], ps: ss_name }; 70 | return { type: urlType(text), json: ss_json, raw: text, id: shortid.generate()}; //id: security method, aid: password 71 | }), 72 | trojan: ( (text) =>{ 73 | const argStartIndex = text.search('\\?'); 74 | const remarkStartIndex = text.search('#'); 75 | const trojan_name = decodeURIComponent(text.slice(remarkStartIndex+1)); // trojan://[password]@[address]:[port]?peer=[host]#[remark] 76 | const trojan_link = text.slice(9,argStartIndex).split(/[@:]+/); 77 | const trojan_peer = text.slice(argStartIndex+1,remarkStartIndex).replace('peer=',''); 78 | const trojan_json = { aid: trojan_link[0], add: trojan_link[1], port: trojan_link[2], host: trojan_peer, ps: trojan_name }; 79 | return {type: urlType(text), json: trojan_json, raw: text, id: shortid.generate()}; 80 | }), 81 | unsupported: ( (text) => { 82 | return { type: urlType(text), json:{}, raw: text, id: shortid.generate() }; 83 | }), 84 | isText: ( (text) => ( text.split(/[,;\n]+/).every( x => validPrefix.test(x) ) )) 85 | }), 86 | json2text: ({ 87 | vmess: ( (json) => ( 'vmess://' + Base64.encode(JSON.stringify(json)) ) ), 88 | ss: ( (json) => { 89 | // chacha20-ietf:password@mydomain.com:8888 90 | // id: method, aid: password 91 | // sip002: 92 | // SS-URI = "ss://" userinfo "@" hostname ":" port [ "/" ] [ "?" plugin ] [ "#" tag ] 93 | // userinfo = websafe-base64-encode-utf8(method ":" password) 94 | const ss_info = json.id + ':' + json.aid; 95 | return 'ss://' + Base64.encode(ss_info) + '@' + json.add + ':' + json.port + '#' + encodeURIComponent(json.ps); 96 | }), 97 | trojan: ( (json) => { 98 | // trojan://[password]@[address]:[port]?peer=#[remark] 99 | return 'trojan://' + json.aid + '@' + json.add + ':' + json.port + '?peer='+ (json.host? json.host:json.add) + '#' + encodeURIComponent(json.ps); 100 | }), 101 | unsupported: ( (json) => json.raw ) 102 | }), 103 | text2qrcode: ( (text) => ) 104 | } 105 | 106 | const urlArray = { 107 | b64ToArr: ((cipher) => { 108 | if(cipher.length && base64regex.test(cipher)){ 109 | const text_list = Base64.decode(cipher).split(/[,;\n]+/); //base64 cipher to text to array of texts 110 | const json_arr = text_list.map(x => textTool.text2json[urlType(x)](x)); // array of texts to array of jsons 111 | //console.log('urlArray.b64ToArr',json_arr); 112 | return json_arr; //array of jsons 113 | }else { 114 | return cipher; //if input is not a base64 cipher, return the original input 115 | } 116 | }), 117 | arrToB64: ((arr) => ( Base64.encode( arr.map( x=> textTool.json2text[x.type](x.json) ).join('\n') ) )) 118 | } 119 | 120 | const submitCustomForm = async (user, pwd, base64text) => { 121 | try { 122 | // check if user and password matched 123 | const objId = await axios({ 124 | method: 'post', 125 | baseURL: apiBaseURL, 126 | url: '/check', 127 | 'Content-Type': 'application/json', 128 | data: {"user": user , "pwd": crypto.createHash('sha256').update(pwd).digest('base64')} 129 | }).then(x => x.data); 130 | if(objId.length){ 131 | // if matched, update records 132 | return axios({ 133 | method: 'put', 134 | baseURL: apiBaseURL, 135 | url: objId[0]._id, 136 | data: {"encrypted": base64text} 137 | }) 138 | }else { 139 | // if not matched, create a new record 140 | return axios({ 141 | method: 'post', 142 | baseURL: apiBaseURL, 143 | 'Content-Type': 'application/json', 144 | data: {"user": user, "pwd": crypto.createHash('sha256').update(pwd).digest('base64') 145 | , "encrypted": base64text} 146 | }) 147 | } 148 | }catch(err) { 149 | console.error(err); 150 | } 151 | 152 | } 153 | 154 | function App() { 155 | const [ inputActive, setInputActive ] = useState('API'); 156 | const [ operateActive, setOperateActive ] = useState('fastEdit'); 157 | 158 | const [ base64Input, setBase64Input ] = useState(''); 159 | const [ textInput, setTextInput ] = useState(''); 160 | const [ subscribeInput, setSubscribeInput ] = useState(''); 161 | 162 | const [ serverList, setServerList ] = useState([]); 163 | const [ serverPointer, setServerPointer ] = useState(0); // index of selected item 164 | 165 | const [ clipboard, setClipboard ] = useClippy(); 166 | 167 | const [ isLoading, setLoading ] = useState(true); 168 | const [ hasEdited, setHasEdited ] = useState(0); 169 | 170 | const [ createdNo, setCreatedNo ] = useState({ss: 0, vmess: 0, trojan: 0}); 171 | 172 | const [ qrcodeVisible, setQrcodeVisible ] = useState(false); 173 | 174 | const [ subLinkVisible, setSubLinkVisible ] = useState(false); 175 | const [ customLink, setCustomLink ] = useState(''); 176 | const [ customFormVisible, setCustomFormVisble ] = useState(false); 177 | const [ customFormLoading, setCustomFormLoading ] = useState(false); 178 | 179 | const [ customLinkUser, setCustomLinkUser ] = useState(''); 180 | const [ customLinkPwd, setCustomLinkPwd ] = useState(''); 181 | 182 | useEffect ( () => { 183 | if(window.location.search){ 184 | const searchParams = new URLSearchParams(window.location.search); 185 | if(searchParams.get('sub') !== null){ 186 | inputOnChange.subscribe({target:{value: searchParams.get('sub') }}) 187 | .then( x => {if(x && searchParams.get('qrcode') === 'yes') setQrcodeVisible(true);} ) 188 | .catch( err => console.error(err) ); 189 | } 190 | } 191 | },[]); // this empty array is a trick to make useEffect to run only once when the page mounted 192 | 193 | const getServerList = (text_b64) => { 194 | try{ 195 | if(base64regex.test(text_b64)) { 196 | //function urlArray.b64ToArr will turn base64 format urls and create a serverList array 197 | //urlArray.b64ToArr(text_b64) is an array from original base64 198 | //urls is an array urlArray.b64ToArr(text_b64) skipping empty liines 199 | const urls = urlArray.b64ToArr(text_b64).filter(x => x.raw !== ""); 200 | if (urls.length < urlArray.b64ToArr(text_b64).length){ 201 | setBase64Input(urlArray.arrToB64(urls)); // update base64 input field if any empty line was skipped 202 | setTextInput(Base64.decode(urlArray.arrToB64(urls))); 203 | } 204 | if (urls.filter(x => supportedType.includes(x.type)).length > 0){ 205 | setServerList(urls); 206 | //console.log('getServerList',urls); 207 | setLoading(false); 208 | return urls; 209 | }else { 210 | return text_b64; 211 | } 212 | } 213 | }catch(err) { 214 | console.log(err); 215 | } 216 | } 217 | 218 | const getSubscription = async (subs) => { 219 | let subArr = subs.split(/[,;\n]+/); 220 | let resultArr = []; 221 | for(let sub of subArr){ 222 | await axios.get(sub) 223 | .then(res => { 224 | resultArr.push(Base64.decode(res.data)); 225 | }).catch(console.error); 226 | } 227 | return resultArr.join('\n'); 228 | } 229 | 230 | const inputOnChange = { 231 | base64: (e) => { 232 | setBase64Input(e.target.value); 233 | if(base64regex.test(e.target.value)) { 234 | try{ 235 | setTextInput(Base64.decode(e.target.value)); 236 | getServerList(e.target.value); 237 | }catch(err){ 238 | console.error(err); 239 | } 240 | } 241 | }, 242 | text: (e) => { 243 | setTextInput(e.target.value); 244 | if(e.target.value === '') { return; } 245 | const text_b64 = Base64.encode(e.target.value); 246 | setBase64Input(text_b64); 247 | if(base64regex.test(text_b64)) { 248 | try{ 249 | getServerList(text_b64); 250 | const params = new URLSearchParams(window.location.search); 251 | params.set('sub',Base64.decode(text_b64)); 252 | window.history.replaceState({}, '', `${window.location.pathname}?${params}`); 253 | }catch(err){ 254 | console.error(err); 255 | } 256 | } 257 | // if equals, mean that input text have not been transferred to urls array 258 | //console.log(text_b64); 259 | }, 260 | subscribe: async (e) => { 261 | const content = e.target.value; 262 | if(content === null) { return; } 263 | 264 | setSubscribeInput(content); 265 | if(textTool.text2json.isText(content)) { 266 | setTextInput(content); 267 | if(e.target.value === '') { return; } 268 | const text_b64 = Base64.encode(content); 269 | setBase64Input(text_b64); 270 | if(base64regex.test(text_b64)) { 271 | try{ 272 | setTextInput(Base64.decode(text_b64)); 273 | getServerList(text_b64); 274 | setInputActive('TEXT'); 275 | }catch(err){ 276 | console.error(err); 277 | } 278 | } 279 | // if equals, mean that input text have not been transferred to urls array 280 | console.log(text_b64); 281 | }else if(/^(http|https).*/.test(content)){ 282 | const key = 'fetching'; 283 | const params = new URLSearchParams(window.location.search); 284 | params.set('sub',content); 285 | window.history.replaceState({}, '', `${window.location.pathname}?${params}`); 286 | message.loading({ content: '導入訂閱鏈接中', key }); 287 | return await getSubscription(content) 288 | .then(x => {setTextInput(x); return Base64.encode(x);}) 289 | .then(x => {setBase64Input(x); return getServerList(x);}) 290 | .then(x => {message.success({ content: ['導入',x.length,'個節點','成功'].join(' '), key, duration: 2 });}) 291 | .catch(err => {console.error(err); message.warning({ content: '導入失敗', key, duration: 2 }); }); 292 | } 293 | } 294 | } 295 | 296 | const importFromClipboard = { 297 | base64: ( () => inputOnChange.base64({target:{value: clipboard}}) ) , 298 | text: ( () => inputOnChange.text({target:{value: clipboard}}) ), 299 | subscribe: ( () => inputOnChange.subscribe({target:{value: clipboard}}) ) 300 | } 301 | 302 | const selectOnChange = { 303 | item: ( (val) => { 304 | //console.log(e); 305 | setServerPointer(serverList.findIndex(x => x.id === val[1])); 306 | }) 307 | } 308 | 309 | 310 | const performSave = () => { 311 | const Base64Output = urlArray.arrToB64(serverList); 312 | const TextOutput = serverList.map(x => textTool.json2text[x.type](x.json) ).join('\n'); 313 | setBase64Input(Base64Output); 314 | setTextInput(TextOutput); 315 | message.success('保存成功'); 316 | console.log(serverList); 317 | setHasEdited(0); 318 | } 319 | 320 | const customLinkForm = { 321 | userOnChange: ( e => setCustomLinkUser(e.target.value.trim()) ), 322 | pwdOnChange: ( e => setCustomLinkPwd(e.target.value)), 323 | submit: ( async () => { 324 | try{ 325 | const params = new URLSearchParams(window.location.search); 326 | setCustomFormLoading(true); 327 | submitCustomForm(customLinkUser, customLinkPwd, base64Input) 328 | .then( x => { return x.data._id } ) 329 | .then( x => { return apiBaseURL+'/'+x }) 330 | .then( x => { 331 | setCustomLink(x); 332 | params.set('sub',x); 333 | window.history.replaceState({}, '', `${window.location.pathname}?${params}`) }) 334 | .then( () => { 335 | message.success('訂閱鏈接己生成'); 336 | setCustomFormLoading(false); 337 | setSubLinkVisible(true); 338 | setCustomFormVisble(false); }) 339 | .catch( (err) => { 340 | console.error(err); 341 | setCustomFormLoading(false); 342 | } ) 343 | }catch(err) { 344 | console.error(err); 345 | setCustomFormLoading(false); 346 | message.error('Internal Error'); 347 | } 348 | }) 349 | } 350 | 351 | const subLinkCreationConfirm = () => { 352 | confirm({title: '確定生成訂閱鏈結?', 353 | icon: , 354 | content: 'This operation will use an API provided by the author, and your data will be confidential. Your links will not be sent by API before confirming . If you have security concern please make your own decision before clicking OK.', 355 | onOk() { 356 | setCustomFormVisble(true); 357 | return; 358 | }, 359 | onCancel(){ 360 | return; 361 | }}) 362 | } 363 | 364 | const subLinkModal = { 365 | btnOnClick: (() => { 366 | if(hasEdited){ 367 | confirm({ 368 | title: '有未保存的修改' , 369 | icon: , 370 | content: '按保存生成訂閱鏈接', 371 | okText: '保存', 372 | cancelText: '取消', 373 | okType: 'danger', 374 | onOk: () => { 375 | setBase64Input( urlArray.arrToB64(serverList) ); 376 | setTextInput( serverList.map(x => textTool.json2text[x.type](x.json) ).join('\n') ); 377 | setHasEdited(0); 378 | message.success('保存成功'); 379 | subLinkCreationConfirm();}, 380 | onCancel: () => { return; }} 381 | ); 382 | }else { 383 | subLinkCreationConfirm(); 384 | } 385 | return; 386 | }), 387 | } 388 | 389 | const qrcodeModal = { 390 | btnOnClick: ( () => { 391 | const params = new URLSearchParams(window.location.search); 392 | if(hasEdited){ 393 | confirm({ 394 | title: '有未保存的修改' , 395 | icon: , 396 | content: '按保存生成二維碼', 397 | okText: '保存', 398 | cancelText: '取消', 399 | okType: 'danger', 400 | onOk() { 401 | setBase64Input( (urlArray.arrToB64(serverList)) ); 402 | setTextInput(serverList.map(x => textTool.json2text[x.type](x.json) ).join('\n') ); 403 | message.success('二維碼己生成'); 404 | setHasEdited(0); 405 | setQrcodeVisible(true); 406 | 407 | params.set('qrcode','yes'); 408 | window.history.replaceState({}, '', `${window.location.pathname}?${params}`); 409 | }, 410 | onCancel() { 411 | setQrcodeVisible(false); 412 | params.delete('qrcode'); 413 | window.history.replaceState({}, '', `${window.location.pathname}?${params}`); 414 | },}); 415 | }else{ 416 | setQrcodeVisible(true); 417 | params.set('qrcode','yes'); 418 | window.history.replaceState({}, '', `${window.location.pathname}?${params}`); 419 | } 420 | return; 421 | }), 422 | close: ( () => { 423 | setQrcodeVisible(false); 424 | const params = new URLSearchParams(window.location.search); 425 | params.delete('qrcode'); 426 | window.history.replaceState({}, '', `${window.location.pathname}?${params}`); 427 | } ), 428 | } 429 | 430 | //const redoOnClick = () => { 431 | //const orignalName = urlName(serverList[serverPointer].raw); 432 | //const selectedId = serverList[serverPointer].id; 433 | //setServerList(serverList.map(item => item.id === selectedId ? {...item, name: orignalName }: item)); 434 | //} 435 | 436 | const deleteOnClick = () => { 437 | confirm({ 438 | title: '確定要刪除' + serverList[serverPointer].json.ps + '?' , 439 | icon: , 440 | content: '這項操作無法復原', 441 | okText: '確定', 442 | cancelText: '取消', 443 | okType: 'danger', 444 | onOk() { 445 | //console.log(serverPointer); 446 | performDelete(serverList[serverPointer]); 447 | //console.log('OK'); 448 | }, 449 | onCancel() { 450 | //console.log('Cancel'); 451 | console.log(serverList); 452 | }, 453 | }); 454 | } 455 | 456 | const performDelete = (obj) => { 457 | let new_pointer = 0; 458 | // move pointer first 459 | if(serverList.filter(item => item.id !== obj.id).length){ 460 | new_pointer = (serverPointer+1)%serverList.filter(item => item.id !== obj.id).length; 461 | } 462 | setServerPointer(new_pointer); 463 | //delete 464 | try{ 465 | const new_base64 = urlArray.arrToB64(serverList.filter(item => item.id !== obj.id)); 466 | const new_urls = urlArray.b64ToArr(new_base64); 467 | setBase64Input(new_base64); 468 | setTextInput(Base64.decode(new_base64)); 469 | setServerList(new_urls); 470 | message.success('刪除 ' + obj.json.ps + ' 成功'); 471 | } catch (err) { 472 | console.error(err); 473 | } 474 | 475 | } 476 | 477 | const getLogo = (type) => { 478 | const logo = { 479 | vmess: (v2Ray), 480 | trojan: (trojan-gfw), 481 | ss: (Shadowsocks) 482 | }; 483 | //console.log(logo[type]); 484 | return logo.hasOwnProperty(type)? logo[type]:() 485 | } 486 | 487 | const editOnChange = { 488 | ps: ( (e) => { 489 | const selectedId = serverList[serverPointer].id; 490 | // Mapping the old array into a new one, swapping what you want to change for an updated item along the way. 491 | setServerList(serverList.map(item => item.id === selectedId ? {...item, json: {...item.json, ps: e.target.value } }: item)); 492 | setHasEdited(1); 493 | }), 494 | net: ( (e) => { 495 | const new_net = e.target.value; 496 | if(serverList[serverPointer].json){ 497 | const selectedId = serverList[serverPointer].id; 498 | if(new_net === 'kcp'){ 499 | setServerList(serverList.map(item => item.id === selectedId ? {...item, json: {...item.json, net: new_net, host: "", path: "", type: textTool.text2json.vmess(serverList[serverPointer].raw).json.type} }: item)); 500 | setHasEdited(1); 501 | }else if(new_net === 'ws'){ 502 | setServerList(serverList.map(item => item.id === selectedId ? {...item, json: {...item.json, net: new_net, host: textTool.text2json.vmess(serverList[serverPointer].raw).json.host, path: textTool.text2json.vmess(serverList[serverPointer].raw).json.path} }: item)); 503 | setHasEdited(1); 504 | }else{ 505 | setServerList(serverList.map(item => item.id === selectedId ? {...item, json: {...item.json, net: new_net, host: "", path: ""} }: item)); 506 | setHasEdited(1); 507 | } 508 | } 509 | }), 510 | address : ((e) => { 511 | const selectedId = serverList[serverPointer].id; 512 | if(serverList[serverPointer].type === 'vmess' && !serverList[serverPointer].json.net) { 513 | setServerList(serverList.map(item => item.id === selectedId ? {...item, json: {...item.json, add: e.target.value, host: e.target.value} }: item)); 514 | }else{ 515 | setServerList(serverList.map(item => item.id === selectedId ? {...item, json: {...item.json, add: e.target.value} }: item)); 516 | } 517 | setHasEdited(1); 518 | }), 519 | port: ((e) => { 520 | const selectedId = serverList[serverPointer].id; 521 | setServerList(serverList.map(item => item.id === selectedId ? {...item, json: {...item.json, port: e.target.value} }: item)); 522 | setHasEdited(1); 523 | }), 524 | uuid: ((e) => { 525 | const selectedId = serverList[serverPointer].id; 526 | setServerList(serverList.map(item => item.id === selectedId ? {...item, json: {...item.json, id: e.target.value} }: item)); 527 | setHasEdited(1); 528 | }), 529 | aid: ((e) => { 530 | const selectedId = serverList[serverPointer].id; 531 | setServerList(serverList.map(item => item.id === selectedId ? {...item, json: {...item.json, aid: e.target.value} }: item)); 532 | setHasEdited(1); 533 | }), 534 | tls: ((checked, e) => { 535 | //console.log(e); 536 | const selectedId = serverList[serverPointer].id; 537 | setServerList(serverList.map(item => item.id === selectedId ? {...item, json: {...item.json, tls: checked? 'tls':'none' } }: item)); 538 | setHasEdited(1); 539 | }), 540 | ws: { 541 | host: ((e) => { 542 | const selectedId = serverList[serverPointer].id; 543 | if(serverList[serverPointer].json.net === 'ws' || serverList[serverPointer].type === 'trojan'){ 544 | setServerList(serverList.map(item => item.id === selectedId ? {...item, json: {...item.json, host: e.target.value} }: item)); 545 | setHasEdited(1); 546 | }else { 547 | return; 548 | } 549 | }), 550 | path: ((e) => { 551 | const selectedId = serverList[serverPointer].id; 552 | if(serverList[serverPointer].json.net === 'ws'){ 553 | setServerList(serverList.map(item => item.id === selectedId ? {...item, json: {...item.json, path: e.target.value} }: item)); 554 | setHasEdited(1); 555 | }else{ 556 | return; 557 | } 558 | }) 559 | }, 560 | type: ((val) => { 561 | const selectedId = serverList[serverPointer].id; 562 | if(serverList[serverPointer].json.net === 'kcp'){ 563 | setServerList(serverList.map(item => item.id === selectedId ? {...item, json: {...item.json, type: val} }: item)); 564 | setHasEdited(1); 565 | } 566 | }), 567 | ssMethod: ( (val) => { 568 | const selectedId = serverList[serverPointer].id; 569 | if(serverList[serverPointer].type === 'ss'){ 570 | setServerList(serverList.map(item => item.id === selectedId ? {...item, json: {...item.json, id: val} }: item)); 571 | setHasEdited(1); 572 | } 573 | }), 574 | create: ( (e) => { 575 | //console.log(e,e.key); 576 | const typeKey = e.key; 577 | const new_createdNo = createdNo[typeKey] + 1; 578 | const new_createdNoJson = JSON.parse(JSON.stringify(createdNo)); 579 | new_createdNoJson[typeKey] = new_createdNo; 580 | setCreatedNo(new_createdNoJson); 581 | 582 | const new_json = defaultJson[typeKey](new_createdNo); 583 | const new_raw = textTool.json2text[typeKey](new_json); 584 | 585 | const new_server = { type: typeKey, json: new_json, raw: new_raw, id: shortid.generate() }; 586 | setServerList([...serverList, new_server]); //correct way to push new item to array in state 587 | setServerPointer(serverList.length? serverList.length:0); 588 | setLoading(false); 589 | const new_text = (textInput.length? (textInput+';'):'') + new_raw; 590 | inputOnChange.base64({target:{value: Base64.encode(new_text)}}); // to prevent success message poping up , set base64 instead of text 591 | setOperateActive('detailedEdit'); 592 | setHasEdited(1); 593 | }) 594 | } 595 | 596 | const inputTabContent = { 597 | API: ( 598 |