Skip to content

Commit 48d8321

Browse files
committed
feat: 支持配置下载 Gist 数据时 Token 处理逻辑
1 parent c9d0e7e commit 48d8321

6 files changed

Lines changed: 190 additions & 25 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.36.21",
3+
"version": "2.36.22",
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/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export const TOKENS_KEY = 'tokens';
1010
export const ARCHIVES_KEY = 'archives';
1111
export const GIST_BACKUP_KEY = 'Auto Generated Sub-Store Backup';
1212
export const GIST_BACKUP_FILE_NAME = 'Sub-Store';
13+
export const GIST_DOWNLOAD_TOKEN_STRATEGIES = ['ask', 'overwrite', 'keep'];
1314
export const ARTIFACT_REPOSITORY_KEY = 'Sub-Store Artifacts Repository';
1415
export const RESOURCE_CACHE_KEY = '#sub-store-cached-resource';
1516
export const HEADERS_RESOURCE_CACHE_KEY = '#sub-store-cached-headers-resource';

backend/src/restful/miscs.js

Lines changed: 83 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ import {
2525
isAgeArmor,
2626
} from '@/utils/age';
2727

28+
const GIST_TOKEN_PATH = 'settings.gistToken';
29+
const GIST_DOWNLOAD_TOKEN_STRATEGY_PATH =
30+
'settings.gistDownloadTokenStrategy';
31+
2832
export default function register($app) {
2933
// utils
3034
$app.get('/api/utils/env', getEnv); // get runtime environment
@@ -211,9 +215,36 @@ async function decryptGistBackupContent(content, settings, encoding) {
211215
return decryptArmorIfPresent(content, ageSecretKey);
212216
}
213217

214-
async function gistBackupAction(action, keep, encode) {
215-
// read token
216-
const { gistToken, syncPlatform } = $.read(SETTINGS_KEY);
218+
function resolveGistDownloadTokenStrategy(
219+
storedStrategy,
220+
queryStrategy,
221+
keep,
222+
) {
223+
if (queryStrategy !== undefined) {
224+
if (queryStrategy !== 'overwrite' && queryStrategy !== 'keep') {
225+
throw new RequestInvalidError(
226+
'INVALID_GIST_DOWNLOAD_TOKEN_STRATEGY',
227+
'Token 处理方式仅支持 overwrite 或 keep',
228+
);
229+
}
230+
return queryStrategy;
231+
}
232+
233+
if (keep !== undefined) {
234+
return String(keep).split(',').includes(GIST_TOKEN_PATH)
235+
? 'keep'
236+
: 'overwrite';
237+
}
238+
239+
return storedStrategy === 'keep' ? 'keep' : 'overwrite';
240+
}
241+
242+
async function gistBackupAction(
243+
action,
244+
{ keep, encode, tokenStrategy: queryTokenStrategy } = {},
245+
) {
246+
const settings = $.read(SETTINGS_KEY);
247+
const { gistToken, syncPlatform } = settings;
217248
if (!gistToken) throw new Error('GitHub Token is required for backup!');
218249

219250
const gist = new Gist({
@@ -223,14 +254,21 @@ async function gistBackupAction(action, keep, encode) {
223254
});
224255
let currentContent = readCurrentBackupContent();
225256
let content;
226-
const settings = $.read(SETTINGS_KEY);
227257
const updated = settings.syncTime;
228258

229259
const encoding = normalizeGistBackupEncoding(
230260
encode || settings.gistUpload || 'base64',
231261
);
262+
const tokenStrategy =
263+
action === 'download'
264+
? resolveGistDownloadTokenStrategy(
265+
settings.gistDownloadTokenStrategy,
266+
queryTokenStrategy,
267+
keep,
268+
)
269+
: undefined;
232270
$.info(
233-
`Gist backup action: ${action}, keep: ${keep}, encode: ${encode}, settings encode: ${settings.gistUpload}, final encoding: ${encoding}`,
271+
`Gist backup action: ${action}, keep: ${keep}, token strategy: ${queryTokenStrategy}, settings token strategy: ${settings.gistDownloadTokenStrategy}, final token strategy: ${tokenStrategy}, encode: ${encode}, settings encode: ${settings.gistUpload}, final encoding: ${encoding}`,
234272
);
235273
switch (action) {
236274
case 'upload':
@@ -288,7 +326,7 @@ async function gistBackupAction(action, keep, encode) {
288326
throw err;
289327
}
290328
break;
291-
case 'download':
329+
case 'download': {
292330
$.info(`还原备份中...`);
293331
content = await gist.download(GIST_BACKUP_FILE_NAME);
294332
content = await decryptGistBackupContent(
@@ -316,12 +354,32 @@ async function gistBackupAction(action, keep, encode) {
316354
throw new Error('Gist 备份文件校验失败, 无法还原');
317355
}
318356
}
319-
if (keep) {
320-
$.info(`保留原有设置 ${keep}`);
321-
keep.split(',').forEach((path) => {
322-
_.set(content, path, _.get(currentContent, path));
323-
});
357+
const keepPaths = keep
358+
? String(keep)
359+
.split(',')
360+
.map((path) => path.trim())
361+
.filter(Boolean)
362+
: [];
363+
const tokenPathIndex = keepPaths.indexOf(GIST_TOKEN_PATH);
364+
if (tokenStrategy === 'keep' && tokenPathIndex === -1) {
365+
keepPaths.push(GIST_TOKEN_PATH);
366+
} else if (
367+
tokenStrategy === 'overwrite' &&
368+
tokenPathIndex !== -1
369+
) {
370+
keepPaths.splice(tokenPathIndex, 1);
324371
}
372+
if (!keepPaths.includes(GIST_DOWNLOAD_TOKEN_STRATEGY_PATH)) {
373+
keepPaths.push(GIST_DOWNLOAD_TOKEN_STRATEGY_PATH);
374+
}
375+
$.info(`保留原有设置 ${keepPaths}`);
376+
keepPaths.forEach((path) => {
377+
if (_.has(currentContent, path)) {
378+
_.set(content, path, _.get(currentContent, path));
379+
} else {
380+
_.unset(content, path);
381+
}
382+
});
325383
// restore settings
326384
$.write(JSON.stringify(content, null, ` `), '#sub-store');
327385
if ($.env.isNode) {
@@ -333,10 +391,11 @@ async function gistBackupAction(action, keep, encode) {
333391
$.info(`migration completed`);
334392
$.info(`还原备份完成`);
335393
break;
394+
}
336395
}
337396
}
338397
async function gistBackup(req, res) {
339-
const { action, keep, encode } = req.query;
398+
const { action, keep, encode, tokenStrategy } = req.query;
340399
// read token
341400
const { gistToken } = $.read(SETTINGS_KEY);
342401
if (!gistToken) {
@@ -349,19 +408,25 @@ async function gistBackup(req, res) {
349408
);
350409
} else {
351410
try {
352-
await gistBackupAction(action, keep, encode);
411+
await gistBackupAction(action, {
412+
keep,
413+
encode,
414+
tokenStrategy,
415+
});
353416
success(res);
354417
} catch (err) {
355418
$.error(
356419
`Failed to ${action} gist data.\nReason: ${err.message ?? err}`,
357420
);
358421
failed(
359422
res,
360-
new InternalServerError(
361-
'BACKUP_FAILED',
362-
`Failed to ${action} gist data!`,
363-
`Reason: ${err.message ?? err}`,
364-
),
423+
err instanceof RequestInvalidError
424+
? err
425+
: new InternalServerError(
426+
'BACKUP_FAILED',
427+
`Failed to ${action} gist data!`,
428+
`Reason: ${err.message ?? err}`,
429+
),
365430
);
366431
}
367432
}

backend/src/restful/settings.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { SETTINGS_KEY, ARTIFACT_REPOSITORY_KEY } from '@/constants';
1+
import {
2+
SETTINGS_KEY,
3+
ARTIFACT_REPOSITORY_KEY,
4+
GIST_DOWNLOAD_TOKEN_STRATEGIES,
5+
} from '@/constants';
26
import { success, failed } from './response';
37
import { InternalServerError, RequestInvalidError } from '@/restful/errors';
48
import $ from '@/core/app';
@@ -169,6 +173,17 @@ async function updateSettings(req, res) {
169173
if (shouldValidateGistAgeSecretKey(newSettings, req.body)) {
170174
await normalizeAndValidateGistAgeSecretKey(newSettings);
171175
}
176+
if (
177+
hasOwn(req.body, 'gistDownloadTokenStrategy') &&
178+
!GIST_DOWNLOAD_TOKEN_STRATEGIES.includes(
179+
req.body.gistDownloadTokenStrategy,
180+
)
181+
) {
182+
throw new RequestInvalidError(
183+
'INVALID_GIST_DOWNLOAD_TOKEN_STRATEGY',
184+
'Token 处理方式仅支持 ask、overwrite 或 keep',
185+
);
186+
}
172187
$.write(newSettings, SETTINGS_KEY);
173188
clearLogSettingsCache();
174189
if (shouldRefreshArtifactStoreForSettingsPatch(req.body)) {

backend/src/test/restful/gist-backup-age.spec.js

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ describe('Gist backup age encryption', function () {
7474
return {};
7575
};
7676

77-
await gistBackupAction('upload', undefined, 'age');
77+
await gistBackupAction('upload', { encode: 'age' });
7878

7979
expect(uploadedContent).to.contain(AGE_ARMOR_HEADER);
8080
const decrypted = await decryptArmorIfPresent(
@@ -108,7 +108,7 @@ describe('Gist backup age encryption', function () {
108108
return {};
109109
};
110110

111-
await gistBackupAction('upload', undefined, 'base64');
111+
await gistBackupAction('upload', { encode: 'base64' });
112112

113113
expect(uploadedContent).not.to.contain(AGE_ARMOR_HEADER);
114114
const backup = JSON.parse(Base64.decode(uploadedContent));
@@ -143,7 +143,7 @@ describe('Gist backup age encryption', function () {
143143
return {};
144144
};
145145

146-
await gistBackupAction('upload', undefined, 'age');
146+
await gistBackupAction('upload', { encode: 'age' });
147147

148148
expect(uploadedContent).to.contain(AGE_ARMOR_HEADER);
149149
});
@@ -186,6 +186,80 @@ describe('Gist backup age encryption', function () {
186186
expect($.cache.subs).to.deep.equal(restoredBackup.subs);
187187
});
188188

189+
it('uses the stored token strategy when downloading', async function () {
190+
installState({
191+
settings: {
192+
gistToken: 'current-token',
193+
gistDownloadTokenStrategy: 'keep',
194+
},
195+
subs: [],
196+
});
197+
198+
Gist.prototype.download = async () =>
199+
Base64.encode(
200+
JSON.stringify({
201+
settings: {
202+
gistToken: 'backup-token',
203+
gistDownloadTokenStrategy: 'overwrite',
204+
},
205+
subs: [],
206+
}),
207+
);
208+
209+
await gistBackupAction('download');
210+
211+
expect($.cache.settings.gistToken).to.equal('current-token');
212+
expect($.cache.settings.gistDownloadTokenStrategy).to.equal('keep');
213+
});
214+
215+
it('lets the query strategy override the stored token strategy', async function () {
216+
installState({
217+
settings: {
218+
gistToken: 'current-token',
219+
gistDownloadTokenStrategy: 'keep',
220+
},
221+
subs: [],
222+
});
223+
224+
Gist.prototype.download = async () =>
225+
Base64.encode(
226+
JSON.stringify({
227+
settings: {
228+
gistToken: 'backup-token',
229+
gistDownloadTokenStrategy: 'overwrite',
230+
},
231+
subs: [],
232+
}),
233+
);
234+
235+
await gistBackupAction('download', {
236+
tokenStrategy: 'overwrite',
237+
});
238+
239+
expect($.cache.settings.gistToken).to.equal('backup-token');
240+
expect($.cache.settings.gistDownloadTokenStrategy).to.equal('keep');
241+
});
242+
243+
it('rejects an invalid query token strategy', async function () {
244+
installState({
245+
settings: {
246+
gistToken: 'current-token',
247+
},
248+
subs: [],
249+
});
250+
251+
let error;
252+
try {
253+
await gistBackupAction('download', {
254+
tokenStrategy: 'invalid',
255+
});
256+
} catch (caught) {
257+
error = caught;
258+
}
259+
260+
expect(error?.message).to.contain('Token 处理方式');
261+
});
262+
189263
it('rejects age upload mode without an age secret key', async function () {
190264
installState({
191265
settings: {
@@ -198,7 +272,7 @@ describe('Gist backup age encryption', function () {
198272
Gist.prototype.download = async () => 'old backup';
199273

200274
try {
201-
await gistBackupAction('upload', undefined, 'age');
275+
await gistBackupAction('upload', { encode: 'age' });
202276
throw new Error('Expected age upload to fail');
203277
} catch (error) {
204278
expect(error.message).to.contain('age 解密私钥');

backend/src/test/restful/settings.spec.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ describe('settings routes', function () {
116116
});
117117
});
118118

119-
describe('age key settings', function () {
119+
describe('validated settings', function () {
120120
const originalRead = $.read.bind($);
121121
const originalWrite = $.write.bind($);
122122

@@ -215,6 +215,16 @@ describe('settings routes', function () {
215215
'age-secret-key 仅支持',
216216
);
217217
});
218+
219+
it('rejects an invalid Gist download token strategy', async function () {
220+
const { res } = await patchSettings(
221+
{},
222+
{ gistDownloadTokenStrategy: 'invalid' },
223+
);
224+
225+
expect(res.body.status).to.equal('failed');
226+
expect(res.body.error.message).to.contain('Token 处理方式');
227+
});
218228
});
219229

220230
describe('appearance settings', function () {

0 commit comments

Comments
 (0)