Skip to content

Commit a09ae7c

Browse files
committed
feat: 分享时长单位逻辑保持用户设置
1 parent 649412d commit a09ae7c

5 files changed

Lines changed: 233 additions & 1 deletion

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.26.0",
3+
"version": "2.27.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/archives.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ function restoreArchivedEntry(entry) {
9292
return createTokenItem(snapshot, {
9393
mode: snapshot.mode,
9494
expiresIn: snapshot.expiresIn,
95+
expiresValue: snapshot.expiresValue,
96+
expiresUnit: snapshot.expiresUnit,
9597
exp: snapshot.exp,
9698
count: snapshot.count,
9799
usedCount: snapshot.usedCount,

backend/src/restful/token.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,10 +168,63 @@ function resolveDurationExpiration(options = {}, { required = false } = {}) {
168168

169169
return {
170170
rawExpiresIn,
171+
expiresIn,
171172
exp: Date.now() + expiresIn,
172173
};
173174
}
174175

176+
const DURATION_INPUT_UNIT_MS = {
177+
day: 24 * 60 * 60 * 1000,
178+
month: 30 * 24 * 60 * 60 * 1000,
179+
season: 90 * 24 * 60 * 60 * 1000,
180+
year: 365 * 24 * 60 * 60 * 1000,
181+
};
182+
183+
function resolveDurationInput(options = {}, durationExpiration) {
184+
const rawExpiresValue = options?.expiresValue;
185+
const rawExpiresUnit = options?.expiresUnit;
186+
const hasExpiresValue = rawExpiresValue != null && rawExpiresValue !== '';
187+
const hasExpiresUnit = rawExpiresUnit != null && rawExpiresUnit !== '';
188+
if (!hasExpiresValue && !hasExpiresUnit) {
189+
return null;
190+
}
191+
192+
const expiresValue = Number(rawExpiresValue);
193+
if (!Number.isFinite(expiresValue) || expiresValue <= 0) {
194+
throw new RequestInvalidError(
195+
'INVALID_EXPIRES_VALUE',
196+
`Invalid expiresValue option: ${rawExpiresValue}`,
197+
);
198+
}
199+
200+
const expiresUnit =
201+
typeof rawExpiresUnit === 'string' ? rawExpiresUnit.trim() : '';
202+
if (!['day', 'month', 'season', 'year'].includes(expiresUnit)) {
203+
throw new RequestInvalidError(
204+
'INVALID_EXPIRES_UNIT',
205+
`Invalid expiresUnit option: ${rawExpiresUnit}`,
206+
);
207+
}
208+
209+
const normalizedExpiresValue = Number(expiresValue.toFixed(2));
210+
const expectedExpiresIn =
211+
normalizedExpiresValue * DURATION_INPUT_UNIT_MS[expiresUnit];
212+
if (
213+
durationExpiration &&
214+
Math.abs(durationExpiration.expiresIn - expectedExpiresIn) > 1
215+
) {
216+
throw new RequestInvalidError(
217+
'INVALID_DURATION_INPUT',
218+
`expiresValue/expiresUnit do not match expiresIn option: ${rawExpiresValue}${expiresUnit} != ${options?.expiresIn}`,
219+
);
220+
}
221+
222+
return {
223+
expiresValue: String(normalizedExpiresValue),
224+
expiresUnit,
225+
};
226+
}
227+
175228
function resolveCountExpiration(options = {}) {
176229
const rawCount = options?.count;
177230
if (rawCount == null || rawCount === '') {
@@ -275,6 +328,7 @@ function createTokenItem(payload, options = {}) {
275328
normalizeExpirationMode(options?.mode) ??
276329
inferLegacyExpirationMode(options);
277330
let durationExpiration = null;
331+
let durationInput = null;
278332
let exp;
279333
let countExpiration = null;
280334
if (expirationMode === 'datetime') {
@@ -285,6 +339,9 @@ function createTokenItem(payload, options = {}) {
285339
durationExpiration = resolveDurationExpiration(options, {
286340
required: expirationMode === 'duration',
287341
});
342+
if (durationExpiration) {
343+
durationInput = resolveDurationInput(options, durationExpiration);
344+
}
288345
exp = durationExpiration?.exp;
289346
}
290347

@@ -314,6 +371,8 @@ function createTokenItem(payload, options = {}) {
314371
delete safePayload.mode;
315372
delete safePayload.exp;
316373
delete safePayload.expiresIn;
374+
delete safePayload.expiresValue;
375+
delete safePayload.expiresUnit;
317376
delete safePayload.count;
318377
delete safePayload.usedCount;
319378
normalizeAgePublicKeyConfig(safePayload);
@@ -332,6 +391,7 @@ function createTokenItem(payload, options = {}) {
332391
: durationExpiration
333392
? {
334393
expiresIn: durationExpiration.rawExpiresIn,
394+
...(durationInput || {}),
335395
exp,
336396
}
337397
: {}),

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,76 @@ describe('archive routes', function () {
378378
expect(state[ARCHIVES_KEY]).to.deep.equal([]);
379379
});
380380

381+
it('preserves duration share input metadata on restore', async function () {
382+
state[SUBS_KEY] = [
383+
{
384+
name: 'shared-sub',
385+
source: 'remote',
386+
process: [],
387+
},
388+
];
389+
state[TOKENS_KEY] = [
390+
{
391+
token: 'duration-input',
392+
type: 'sub',
393+
name: 'shared-sub',
394+
displayName: 'Duration Input Share',
395+
mode: 'duration',
396+
expiresIn: '35d',
397+
expiresValue: '35',
398+
expiresUnit: 'day',
399+
},
400+
];
401+
402+
const deleteHandler = getHandler(
403+
registerTokenRoutes,
404+
'DELETE',
405+
'/api/token/:token',
406+
);
407+
const deleteRes = createResponse('/api/token/:token');
408+
409+
deleteHandler(
410+
{
411+
params: { token: 'duration-input' },
412+
query: {
413+
mode: 'archive',
414+
type: 'sub',
415+
name: 'shared-sub',
416+
},
417+
},
418+
deleteRes,
419+
);
420+
421+
expect(deleteRes.body.status).to.equal('success');
422+
expect(state[TOKENS_KEY]).to.deep.equal([]);
423+
expect(state[ARCHIVES_KEY]).to.have.length(1);
424+
425+
const restoreHandler = getHandler(
426+
registerArchiveRoutes,
427+
'POST',
428+
'/api/archives/:id/restore',
429+
);
430+
const restoreRes = createResponse('/api/archives/:id/restore');
431+
432+
restoreHandler(
433+
{
434+
params: { id: state[ARCHIVES_KEY][0].id },
435+
query: {},
436+
},
437+
restoreRes,
438+
);
439+
440+
expect(restoreRes.body.status).to.equal('success');
441+
expect(state[TOKENS_KEY]).to.have.length(1);
442+
expect(state[TOKENS_KEY][0].token).to.equal('duration-input');
443+
expect(state[TOKENS_KEY][0].mode).to.equal('duration');
444+
expect(state[TOKENS_KEY][0].expiresIn).to.equal('35d');
445+
expect(state[TOKENS_KEY][0].expiresValue).to.equal('35');
446+
expect(state[TOKENS_KEY][0].expiresUnit).to.equal('day');
447+
expect(state[TOKENS_KEY][0].exp).to.be.a('number');
448+
expect(state[ARCHIVES_KEY]).to.deep.equal([]);
449+
});
450+
381451
it('restores legacy exp-only share tokens as exact-datetime shares', async function () {
382452
state[SUBS_KEY] = [
383453
{

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

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,100 @@ describe('token routes', function () {
234234
expect(token.exp).to.be.a('number');
235235
});
236236

237+
it('stores original duration input for frontend restoration', function () {
238+
const token = createTokenItem(
239+
{
240+
type: 'sub',
241+
name: 'shared-sub',
242+
token: 'duration-input',
243+
},
244+
{
245+
mode: 'duration',
246+
expiresIn: '35d',
247+
expiresValue: '35',
248+
expiresUnit: 'day',
249+
},
250+
);
251+
252+
expect(token.mode).to.equal('duration');
253+
expect(token.expiresIn).to.equal('35d');
254+
expect(token.expiresValue).to.equal('35');
255+
expect(token.expiresUnit).to.equal('day');
256+
expect(state[TOKENS_KEY][0].expiresValue).to.equal('35');
257+
expect(state[TOKENS_KEY][0].expiresUnit).to.equal('day');
258+
});
259+
260+
it('accepts season duration input when it matches expiresIn', function () {
261+
const token = createTokenItem(
262+
{
263+
type: 'sub',
264+
name: 'shared-sub',
265+
token: 'duration-season',
266+
},
267+
{
268+
mode: 'duration',
269+
expiresIn: '90d',
270+
expiresValue: '1',
271+
expiresUnit: 'season',
272+
},
273+
);
274+
275+
expect(token.mode).to.equal('duration');
276+
expect(token.expiresIn).to.equal('90d');
277+
expect(token.expiresValue).to.equal('1');
278+
expect(token.expiresUnit).to.equal('season');
279+
expect(state[TOKENS_KEY][0].expiresValue).to.equal('1');
280+
expect(state[TOKENS_KEY][0].expiresUnit).to.equal('season');
281+
});
282+
283+
it('rejects invalid duration input metadata', function () {
284+
for (const options of [
285+
{
286+
mode: 'duration',
287+
expiresIn: '35d',
288+
expiresValue: '0',
289+
expiresUnit: 'day',
290+
code: 'INVALID_EXPIRES_VALUE',
291+
},
292+
{
293+
mode: 'duration',
294+
expiresIn: '35d',
295+
expiresValue: '35',
296+
expiresUnit: 'week',
297+
code: 'INVALID_EXPIRES_UNIT',
298+
},
299+
{
300+
mode: 'duration',
301+
expiresIn: '1d',
302+
expiresValue: '2',
303+
expiresUnit: 'year',
304+
code: 'INVALID_DURATION_INPUT',
305+
},
306+
]) {
307+
let capturedError;
308+
309+
try {
310+
createTokenItem(
311+
{
312+
type: 'sub',
313+
name: 'shared-sub',
314+
token: `duration-input-${options.code}`,
315+
},
316+
options,
317+
);
318+
} catch (error) {
319+
capturedError = error;
320+
}
321+
322+
expect(capturedError).to.include({
323+
type: 'RequestInvalidError',
324+
code: options.code,
325+
});
326+
}
327+
328+
expect(state[TOKENS_KEY]).to.deep.equal([]);
329+
});
330+
237331
it('infers legacy exact datetime behavior when mode is omitted but exp exists', function () {
238332
const exp = Date.now() - 5 * 60 * 1000;
239333

@@ -264,16 +358,22 @@ describe('token routes', function () {
264358
mode: 'datetime',
265359
exp: Date.now() + 60 * 1000,
266360
expiresIn: '1d',
361+
expiresValue: '35',
362+
expiresUnit: 'day',
267363
},
268364
{},
269365
);
270366

271367
expect(token).to.not.have.property('mode');
272368
expect(token).to.not.have.property('exp');
273369
expect(token).to.not.have.property('expiresIn');
370+
expect(token).to.not.have.property('expiresValue');
371+
expect(token).to.not.have.property('expiresUnit');
274372
expect(state[TOKENS_KEY][0]).to.not.have.property('mode');
275373
expect(state[TOKENS_KEY][0]).to.not.have.property('exp');
276374
expect(state[TOKENS_KEY][0]).to.not.have.property('expiresIn');
375+
expect(state[TOKENS_KEY][0]).to.not.have.property('expiresValue');
376+
expect(state[TOKENS_KEY][0]).to.not.have.property('expiresUnit');
277377
});
278378

279379
it('preserves age-public-key in share token payload', async function () {

0 commit comments

Comments
 (0)