Skip to content

Commit fd74d98

Browse files
committed
feat: 支持 Gist 备份使用 age 加密并优化设置页配置
1 parent 916ae98 commit fd74d98

7 files changed

Lines changed: 535 additions & 41 deletions

File tree

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sub-store",
3-
"version": "2.28.2",
3+
"version": "2.29.0",
44
"description": "Advanced Subscription Manager for QX, Loon, Surge, Stash and Shadowrocket.",
55
"main": "src/main.js",
66
"packageManager": "pnpm@11.0.9",

backend/src/restful/miscs.js

Lines changed: 102 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import _ from 'lodash';
33
import $ from '@/core/app';
44
import { ENV } from '@/vendor/open-api';
55
import { failed, success } from '@/restful/response';
6-
import { updateArtifactStore, updateAvatar } from '@/restful/settings';
6+
import { updateArtifactStore } from '@/restful/settings';
77
import resourceCache from '@/utils/resource-cache';
88
import scriptResourceCache from '@/utils/script-resource-cache';
99
import headersResourceCache from '@/utils/headers-resource-cache';
@@ -17,6 +17,13 @@ import Gist from '@/utils/gist';
1717
import migrate from '@/utils/migration';
1818
import env from '@/utils/env';
1919
import { formatDateTime } from '@/utils';
20+
import {
21+
AGE_SECRET_KEY,
22+
decryptArmorIfPresent,
23+
derivePublicKey,
24+
encryptArmor,
25+
isAgeArmor,
26+
} from '@/utils/age';
2027

2128
export default function register($app) {
2229
// utils
@@ -144,6 +151,65 @@ async function refresh(_, res) {
144151
success(res);
145152
}
146153

154+
function readCurrentBackupContent() {
155+
let content = $.read('#sub-store');
156+
content = content ? JSON.parse(content) : {};
157+
if ($.env.isNode) content = JSON.parse(JSON.stringify($.cache));
158+
return content;
159+
}
160+
161+
function serializeGistBackupContent(content, encoding, options = {}) {
162+
const backup = JSON.parse(JSON.stringify(content || {}));
163+
if (!options.keepAgeSecretKey && backup.settings?.[AGE_SECRET_KEY]) {
164+
delete backup.settings[AGE_SECRET_KEY];
165+
}
166+
if (encoding === 'plaintext') {
167+
backup.settings = backup.settings || {};
168+
backup.settings.gistToken = '恢复后请重新设置 GitHub Token';
169+
return JSON.stringify(backup, null, ` `);
170+
}
171+
172+
return Base64.encode(JSON.stringify(backup, null, ` `));
173+
}
174+
175+
function normalizeGistBackupEncoding(encoding) {
176+
return ['base64', 'plaintext', 'age'].includes(encoding)
177+
? encoding
178+
: 'base64';
179+
}
180+
181+
function getGistBackupPayloadEncoding(encoding) {
182+
return encoding === 'plaintext' ? 'plaintext' : 'base64';
183+
}
184+
185+
function isAgeGistBackupEncoding(encoding) {
186+
return encoding === 'age';
187+
}
188+
189+
async function encryptGistBackupContent(content, settings, encoding) {
190+
if (!isAgeGistBackupEncoding(encoding)) return content;
191+
192+
const ageSecretKey = settings?.[AGE_SECRET_KEY];
193+
if (!ageSecretKey) {
194+
throw new Error('age 加密模式需要配置 age 解密私钥');
195+
}
196+
197+
$.info(`使用 age 加密 Gist 备份内容`);
198+
return encryptArmor(content, await derivePublicKey(ageSecretKey));
199+
}
200+
201+
async function decryptGistBackupContent(content, settings, encoding) {
202+
if (!isAgeGistBackupEncoding(encoding)) return content;
203+
204+
const ageSecretKey = settings?.[AGE_SECRET_KEY];
205+
if (!ageSecretKey) {
206+
throw new Error('age 加密模式需要配置 age 解密私钥');
207+
}
208+
209+
$.info(`尝试使用 age 解密 Gist 备份内容`);
210+
return decryptArmorIfPresent(content, ageSecretKey);
211+
}
212+
147213
async function gistBackupAction(action, keep, encode) {
148214
// read token
149215
const { gistToken, syncPlatform } = $.read(SETTINGS_KEY);
@@ -154,38 +220,40 @@ async function gistBackupAction(action, keep, encode) {
154220
key: GIST_BACKUP_KEY,
155221
syncPlatform,
156222
});
157-
let currentContent = $.read('#sub-store');
158-
currentContent = currentContent ? JSON.parse(currentContent) : {};
159-
if ($.env.isNode) currentContent = JSON.parse(JSON.stringify($.cache));
223+
let currentContent = readCurrentBackupContent();
160224
let content;
161225
const settings = $.read(SETTINGS_KEY);
162226
const updated = settings.syncTime;
163227

164-
const encoding = encode || settings.gistUpload || 'base64';
228+
const encoding = normalizeGistBackupEncoding(
229+
encode || settings.gistUpload || 'base64',
230+
);
165231
$.info(
166232
`Gist backup action: ${action}, keep: ${keep}, encode: ${encode}, settings encode: ${settings.gistUpload}, final encoding: ${encoding}`,
167233
);
168234
switch (action) {
169235
case 'upload':
170236
try {
171-
content = $.read('#sub-store');
172-
content = content ? JSON.parse(content) : {};
173-
if ($.env.isNode) content = JSON.parse(JSON.stringify($.cache));
174-
if (encoding === 'plaintext') {
175-
content.settings.gistToken =
176-
'恢复后请重新设置 GitHub Token';
177-
content = JSON.stringify(content, null, ` `);
178-
} else {
179-
content = Base64.encode(
180-
JSON.stringify(content, null, ` `),
181-
);
182-
}
237+
const keepAgeSecretKey = isAgeGistBackupEncoding(encoding);
238+
content = serializeGistBackupContent(
239+
readCurrentBackupContent(),
240+
getGistBackupPayloadEncoding(encoding),
241+
{ keepAgeSecretKey },
242+
);
183243

184244
$.info(`下载备份, 与本地内容对比...`);
185-
const onlineContent = await gist.download(
245+
const downloadedContent = await gist.download(
186246
GIST_BACKUP_FILE_NAME,
187247
);
188-
if (onlineContent === content) {
248+
const onlineContent = await decryptGistBackupContent(
249+
downloadedContent,
250+
settings,
251+
encoding,
252+
);
253+
const canReuseOnlineContent =
254+
!isAgeGistBackupEncoding(encoding) ||
255+
isAgeArmor(downloadedContent);
256+
if (canReuseOnlineContent && onlineContent === content) {
189257
$.info(`内容一致, 无需上传备份`);
190258
return;
191259
}
@@ -196,15 +264,16 @@ async function gistBackupAction(action, keep, encode) {
196264
// update syncTime
197265
settings.syncTime = new Date().getTime();
198266
$.write(settings, SETTINGS_KEY);
199-
content = $.read('#sub-store');
200-
content = content ? JSON.parse(content) : {};
201-
if ($.env.isNode) content = JSON.parse(JSON.stringify($.cache));
202-
if (encoding === 'plaintext') {
203-
content.settings.gistToken = '恢复后请重新设置 GitHub Token';
204-
content = JSON.stringify(content, null, ` `);
205-
} else {
206-
content = Base64.encode(JSON.stringify(content, null, ` `));
207-
}
267+
content = serializeGistBackupContent(
268+
readCurrentBackupContent(),
269+
getGistBackupPayloadEncoding(encoding),
270+
{ keepAgeSecretKey: isAgeGistBackupEncoding(encoding) },
271+
);
272+
content = await encryptGistBackupContent(
273+
content,
274+
settings,
275+
encoding,
276+
);
208277
$.info(`上传备份中...`);
209278
try {
210279
await gist.upload({
@@ -221,6 +290,11 @@ async function gistBackupAction(action, keep, encode) {
221290
case 'download':
222291
$.info(`还原备份中...`);
223292
content = await gist.download(GIST_BACKUP_FILE_NAME);
293+
content = await decryptGistBackupContent(
294+
content,
295+
settings,
296+
encoding,
297+
);
224298
try {
225299
content = JSON.parse(Base64.decode(content));
226300
if (!(Object.keys(content.settings).length >= 0)) {

backend/src/restful/settings.js

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import { SETTINGS_KEY, ARTIFACT_REPOSITORY_KEY } from '@/constants';
22
import { success, failed } from './response';
3-
import { InternalServerError } from '@/restful/errors';
3+
import { InternalServerError, RequestInvalidError } from '@/restful/errors';
44
import $ from '@/core/app';
55
import Gist, { getGithubGistBaseURL } from '@/utils/gist';
66
import { clearLogSettingsCache } from '@/utils/debug-logs';
77
import {
88
BACKEND_REQUEST_CONCURRENCY_SETTING,
99
BACKEND_REQUEST_CONCURRENCY_WAIT_TIME_SETTING,
1010
} from '@/utils/request-concurrency';
11+
import {
12+
AGE_SECRET_KEY,
13+
normalizeAgeSecretKeyConfig,
14+
} from '@/utils/age';
1115

1216
const ARTIFACT_STORE_SETTING_KEYS = [
1317
'gistToken',
@@ -16,6 +20,34 @@ const ARTIFACT_STORE_SETTING_KEYS = [
1620
'defaultProxy',
1721
];
1822

23+
function isPlainObject(value) {
24+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
25+
}
26+
27+
function hasOwn(object, key) {
28+
return Object.prototype.hasOwnProperty.call(object || {}, key);
29+
}
30+
31+
function shouldValidateGistAgeSecretKey(settings, body = {}) {
32+
return (
33+
body.gistUpload === 'age' ||
34+
(settings?.gistUpload === 'age' &&
35+
hasOwn(body, AGE_SECRET_KEY))
36+
);
37+
}
38+
39+
async function normalizeAndValidateGistAgeSecretKey(settings) {
40+
await normalizeAgeSecretKeyConfig(settings);
41+
42+
const ageSecretKey = settings[AGE_SECRET_KEY];
43+
if (!ageSecretKey) {
44+
throw new RequestInvalidError(
45+
'INVALID_GIST_AGE_KEYS',
46+
'age 加密模式需要配置 age-secret-key',
47+
);
48+
}
49+
}
50+
1951
export function shouldRefreshArtifactStoreForSettingsPatch(body = {}) {
2052
return ARTIFACT_STORE_SETTING_KEYS.some((key) =>
2153
Object.prototype.hasOwnProperty.call(body, key),
@@ -69,6 +101,14 @@ async function updateSettings(req, res) {
69101
...settings,
70102
...req.body,
71103
};
104+
if (isPlainObject(req.body?.appearanceSetting)) {
105+
newSettings.appearanceSetting = {
106+
...(isPlainObject(settings?.appearanceSetting)
107+
? settings.appearanceSetting
108+
: {}),
109+
...req.body.appearanceSetting,
110+
};
111+
}
72112
[
73113
'defaultTimeout',
74114
'githubApiTimeout',
@@ -126,6 +166,9 @@ async function updateSettings(req, res) {
126166
];
127167
}
128168
}
169+
if (shouldValidateGistAgeSecretKey(newSettings, req.body)) {
170+
await normalizeAndValidateGistAgeSecretKey(newSettings);
171+
}
129172
$.write(newSettings, SETTINGS_KEY);
130173
clearLogSettingsCache();
131174
if (shouldRefreshArtifactStoreForSettingsPatch(req.body)) {
@@ -137,11 +180,13 @@ async function updateSettings(req, res) {
137180
$.error(`Failed to update settings: ${e.message ?? e}`);
138181
failed(
139182
res,
140-
new InternalServerError(
141-
`FAILED_TO_UPDATE_SETTINGS`,
142-
`Failed to update settings`,
143-
`Reason: ${e.message ?? e}`,
144-
),
183+
e instanceof RequestInvalidError
184+
? e
185+
: new InternalServerError(
186+
`FAILED_TO_UPDATE_SETTINGS`,
187+
`Failed to update settings`,
188+
`Reason: ${e.message ?? e}`,
189+
),
145190
);
146191
}
147192
}

0 commit comments

Comments
 (0)