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 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | 小火箭訂閱編輯器
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/src/serviceWorker.js:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.0/8 are considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | export function register(config) {
24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener('load', () => {
35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Let's check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl, config);
40 |
41 | // Add some additional logging to localhost, pointing developers to the
42 | // service worker/PWA documentation.
43 | navigator.serviceWorker.ready.then(() => {
44 | console.log(
45 | 'This web app is being served cache-first by a service ' +
46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
47 | );
48 | });
49 | } else {
50 | // Is not localhost. Just register service worker
51 | registerValidSW(swUrl, config);
52 | }
53 | });
54 | }
55 | }
56 |
57 | function registerValidSW(swUrl, config) {
58 | navigator.serviceWorker
59 | .register(swUrl)
60 | .then(registration => {
61 | registration.onupdatefound = () => {
62 | const installingWorker = registration.installing;
63 | if (installingWorker == null) {
64 | return;
65 | }
66 | installingWorker.onstatechange = () => {
67 | if (installingWorker.state === 'installed') {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the updated precached content has been fetched,
70 | // but the previous service worker will still serve the older
71 | // content until all client tabs are closed.
72 | console.log(
73 | 'New content is available and will be used when all ' +
74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
75 | );
76 |
77 | // Execute callback
78 | if (config && config.onUpdate) {
79 | config.onUpdate(registration);
80 | }
81 | } else {
82 | // At this point, everything has been precached.
83 | // It's the perfect time to display a
84 | // "Content is cached for offline use." message.
85 | console.log('Content is cached for offline use.');
86 |
87 | // Execute callback
88 | if (config && config.onSuccess) {
89 | config.onSuccess(registration);
90 | }
91 | }
92 | }
93 | };
94 | };
95 | })
96 | .catch(error => {
97 | console.error('Error during service worker registration:', error);
98 | });
99 | }
100 |
101 | function checkValidServiceWorker(swUrl, config) {
102 | // Check if the service worker can be found. If it can't reload the page.
103 | fetch(swUrl, {
104 | headers: { 'Service-Worker': 'script' }
105 | })
106 | .then(response => {
107 | // Ensure service worker exists, and that we really are getting a JS file.
108 | const contentType = response.headers.get('content-type');
109 | if (
110 | response.status === 404 ||
111 | (contentType != null && contentType.indexOf('javascript') === -1)
112 | ) {
113 | // No service worker found. Probably a different app. Reload the page.
114 | navigator.serviceWorker.ready.then(registration => {
115 | registration.unregister().then(() => {
116 | window.location.reload();
117 | });
118 | });
119 | } else {
120 | // Service worker found. Proceed as normal.
121 | registerValidSW(swUrl, config);
122 | }
123 | })
124 | .catch(() => {
125 | console.log(
126 | 'No internet connection found. App is running in offline mode.'
127 | );
128 | });
129 | }
130 |
131 | export function unregister() {
132 | if ('serviceWorker' in navigator) {
133 | navigator.serviceWorker.ready
134 | .then(registration => {
135 | registration.unregister();
136 | })
137 | .catch(error => {
138 | console.error(error.message);
139 | });
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/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: (
),
480 | trojan: (
),
481 | ss: (
)
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 |
),
600 | TEXT: (
601 |
),
603 | BASE64: (
604 |
),
606 | _buttons: (
607 |
608 |
609 | )
610 | }
611 |
612 | const commonContent = {
613 | select: (
614 |
618 |
619 | {supportedType.map( x => ({getLogo(x)} {x.toUpperCase()}) )}
620 | }>}/>
621 | } onClick={deleteOnClick} danger/>
622 |
),
623 | remark: ()}
624 | value={serverList[serverPointer] && serverList[serverPointer].hasOwnProperty('json')? serverList[serverPointer].json.ps:''} disabled={isLoading || !base64Input.length} onChange={editOnChange.ps} onPressEnter={editOnChange.ps}/>),
625 | serverAddress: (
626 |
627 |
628 | ),
629 | skeleton: (
),
630 | }
631 |
632 | const detailedContent = {
633 | vmess: (
634 | {commonContent.select}
635 | {commonContent.remark}
636 | {commonContent.serverAddress}
637 |
638 |
639 |
640 |
641 |
642 |
643 |
644 |
645 | TCP
646 | WS
647 | KCP
648 |
649 |
651 |
652 |
655 |
656 |
657 |
658 |
659 |
660 |
663 |
671 |
672 |
),
673 | ss: (
674 | {commonContent.select}
675 | {commonContent.remark}
676 | {commonContent.serverAddress}
677 |
678 |
679 |
683 |
684 |
685 |
686 |
687 |
688 | ),
689 | trojan: (
690 | {commonContent.select}
691 | {commonContent.remark}
692 | {commonContent.serverAddress}
693 |
694 |
695 |
696 |
697 |
698 |
699 |
700 |
701 |
702 | )
703 |
704 | }
705 |
706 | const operateTabContent = {
707 | fastEdit: (
708 |
709 | {commonContent.select}
710 | {commonContent.remark}
711 | {commonContent.serverAddress}
712 |
),
713 | detailedEdit: ( serverList[serverPointer]? (supportedType.includes(serverList[serverPointer].type)? detailedContent[serverList[serverPointer].type]:commonContent.skeleton):(commonContent.skeleton)),
714 | _buttons : (
715 |
716 | } disabled={isLoading || !base64Input.length} onClick={qrcodeModal.btnOnClick}>
717 |
718 |
719 | } disabled={isLoading || !base64Input.length} onClick={performSave}>保存
720 |
721 | )
722 | }
723 |
724 | return (
725 |
772 | );
773 | }
774 |
775 | export default App;
776 |
--------------------------------------------------------------------------------