Skip to content

Commit ea8bb4d

Browse files
committed
feat: 分享支持直接设置到期时间
1 parent e2d6b83 commit ea8bb4d

5 files changed

Lines changed: 529 additions & 16 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.22.5",
3+
"version": "2.22.6",
44
"description": "Advanced Subscription Manager for QX, Loon, Surge, Stash and Shadowrocket.",
55
"main": "src/main.js",
66
"scripts": {

backend/src/restful/archives.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,9 @@ function restoreArchivedEntry(entry) {
9090
return createArtifactItem(normalizeArtifactSnapshotForRestore(snapshot));
9191
case 'share':
9292
return createTokenItem(snapshot, {
93+
mode: snapshot.mode,
9394
expiresIn: snapshot.expiresIn,
95+
exp: snapshot.exp,
9496
});
9597
default:
9698
throw new RequestInvalidError(

backend/src/restful/token.js

Lines changed: 116 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,91 @@ async function signToken(req, res) {
8787
}
8888
}
8989

90-
function createTokenItem(payload, options = {}) {
90+
function normalizeExpirationMode(mode) {
91+
if (mode == null || mode === '') {
92+
return undefined;
93+
}
94+
if (mode === 'duration' || mode === 'datetime') {
95+
return mode;
96+
}
97+
throw new RequestInvalidError(
98+
'INVALID_EXPIRATION_MODE',
99+
`Unsupported expiration mode: ${mode}`,
100+
);
101+
}
102+
103+
function inferLegacyExpirationMode(options = {}) {
104+
if (options?.expiresIn != null && options.expiresIn !== '') {
105+
return 'duration';
106+
}
107+
if (options?.exp != null && options.exp !== '') {
108+
return 'datetime';
109+
}
110+
return undefined;
111+
}
112+
113+
function resolveExactExpiration(options = {}) {
114+
const rawExp = options?.exp;
115+
if (typeof rawExp !== 'number' && typeof rawExp !== 'string') {
116+
throw new RequestInvalidError(
117+
'INVALID_EXPIRATION_DATETIME',
118+
`Invalid exp option: ${rawExp}`,
119+
);
120+
}
121+
122+
const normalizedRawExp =
123+
typeof rawExp === 'string' ? rawExp.trim() : rawExp;
124+
if (normalizedRawExp === '') {
125+
throw new RequestInvalidError(
126+
'INVALID_EXPIRATION_DATETIME',
127+
`Invalid exp option: ${rawExp}`,
128+
);
129+
}
130+
131+
const exp = Number(normalizedRawExp);
132+
if (
133+
!Number.isSafeInteger(exp) ||
134+
exp <= 0 ||
135+
// Require an explicit millisecond Unix timestamp to avoid
136+
// silently accepting second-based values from non-frontend callers.
137+
exp < 1000000000000
138+
) {
139+
throw new RequestInvalidError(
140+
'INVALID_EXPIRATION_DATETIME',
141+
`Invalid exp option: ${rawExp}`,
142+
);
143+
}
144+
return exp;
145+
}
146+
147+
function resolveDurationExpiration(options = {}, { required = false } = {}) {
148+
const rawExpiresIn = options?.expiresIn;
149+
if (rawExpiresIn == null || rawExpiresIn === '') {
150+
if (required) {
151+
throw new RequestInvalidError(
152+
'INVALID_EXPIRES_IN',
153+
`Invalid expiresIn option: ${rawExpiresIn}`,
154+
);
155+
}
156+
return null;
157+
}
158+
91159
const ms = eval(`require("ms")`);
160+
const expiresIn = ms(rawExpiresIn);
161+
if (expiresIn == null || isNaN(expiresIn) || expiresIn <= 0) {
162+
throw new RequestInvalidError(
163+
'INVALID_EXPIRES_IN',
164+
`Invalid expiresIn option: ${rawExpiresIn}`,
165+
);
166+
}
167+
168+
return {
169+
rawExpiresIn,
170+
exp: Date.now() + expiresIn,
171+
};
172+
}
173+
174+
function createTokenItem(payload, options = {}) {
92175
const type = payload?.type;
93176
const name = payload?.name;
94177
if (!type || !name) {
@@ -155,15 +238,18 @@ function createTokenItem(payload, options = {}) {
155238
);
156239
}
157240

158-
let expiresIn = options?.expiresIn;
159-
if (options?.expiresIn != null) {
160-
expiresIn = ms(options.expiresIn);
161-
if (expiresIn == null || isNaN(expiresIn) || expiresIn <= 0) {
162-
throw new RequestInvalidError(
163-
'INVALID_EXPIRES_IN',
164-
`Invalid expiresIn option: ${options.expiresIn}`,
165-
);
166-
}
241+
const expirationMode =
242+
normalizeExpirationMode(options?.mode) ??
243+
inferLegacyExpirationMode(options);
244+
let durationExpiration = null;
245+
let exp;
246+
if (expirationMode === 'datetime') {
247+
exp = resolveExactExpiration(options);
248+
} else {
249+
durationExpiration = resolveDurationExpiration(options, {
250+
required: expirationMode === 'duration',
251+
});
252+
exp = durationExpiration?.exp;
167253
}
168254

169255
const nanoid = eval(`require("nanoid")`);
@@ -180,12 +266,29 @@ function createTokenItem(payload, options = {}) {
180266
)
181267
);
182268
}
269+
const normalizedMode =
270+
expirationMode === 'datetime'
271+
? 'datetime'
272+
: durationExpiration
273+
? 'duration'
274+
: undefined;
275+
const safePayload = { ...payload };
276+
delete safePayload.mode;
277+
delete safePayload.exp;
278+
delete safePayload.expiresIn;
183279
const tokenData = {
184-
...payload,
280+
...safePayload,
185281
token,
186282
createdAt: Date.now(),
187-
expiresIn: expiresIn > 0 ? options?.expiresIn : undefined,
188-
exp: expiresIn > 0 ? Date.now() + expiresIn : undefined,
283+
...(normalizedMode ? { mode: normalizedMode } : {}),
284+
...(normalizedMode === 'datetime'
285+
? { exp }
286+
: durationExpiration
287+
? {
288+
expiresIn: durationExpiration.rawExpiresIn,
289+
exp,
290+
}
291+
: {}),
189292
};
190293
insertByPosition(tokens, tokenData, getCreateItemPosition());
191294
$.write(tokens, TOKENS_KEY);

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

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,21 +219,23 @@ describe('archive routes', function () {
219219
expect(state[ARCHIVES_KEY]).to.deep.equal([]);
220220
});
221221

222-
it('keeps archived entries when restore fails and preserves share tokens on success', async function () {
222+
it('keeps archived entries when restore fails and preserves exact-datetime share tokens on success', async function () {
223223
state[SUBS_KEY] = [
224224
{
225225
name: 'shared-sub',
226226
source: 'remote',
227227
process: [],
228228
},
229229
];
230+
const exactExp = Date.now() - 5 * 60 * 1000;
230231
state[TOKENS_KEY] = [
231232
{
232233
token: 'keep-me',
233234
type: 'sub',
234235
name: 'shared-sub',
235236
displayName: 'Shared Link',
236-
expiresIn: '1d',
237+
mode: 'datetime',
238+
exp: exactExp,
237239
},
238240
];
239241

@@ -305,6 +307,140 @@ describe('archive routes', function () {
305307
expect(successfulRestoreRes.body.status).to.equal('success');
306308
expect(state[TOKENS_KEY]).to.have.length(1);
307309
expect(state[TOKENS_KEY][0].token).to.equal('keep-me');
310+
expect(state[TOKENS_KEY][0].mode).to.equal('datetime');
311+
expect(state[TOKENS_KEY][0].exp).to.equal(exactExp);
312+
expect(state[TOKENS_KEY][0]).to.not.have.property('expiresIn');
313+
expect(state[ARCHIVES_KEY]).to.deep.equal([]);
314+
});
315+
316+
it('preserves legacy duration share tokens on restore', async function () {
317+
state[SUBS_KEY] = [
318+
{
319+
name: 'shared-sub',
320+
source: 'remote',
321+
process: [],
322+
},
323+
];
324+
state[TOKENS_KEY] = [
325+
{
326+
token: 'legacy-duration',
327+
type: 'sub',
328+
name: 'shared-sub',
329+
displayName: 'Legacy Share',
330+
expiresIn: '1d',
331+
},
332+
];
333+
334+
const deleteHandler = getHandler(
335+
registerTokenRoutes,
336+
'DELETE',
337+
'/api/token/:token',
338+
);
339+
const deleteRes = createResponse('/api/token/:token');
340+
341+
deleteHandler(
342+
{
343+
params: { token: 'legacy-duration' },
344+
query: {
345+
mode: 'archive',
346+
type: 'sub',
347+
name: 'shared-sub',
348+
},
349+
},
350+
deleteRes,
351+
);
352+
353+
expect(deleteRes.body.status).to.equal('success');
354+
expect(state[TOKENS_KEY]).to.deep.equal([]);
355+
expect(state[ARCHIVES_KEY]).to.have.length(1);
356+
357+
const restoreHandler = getHandler(
358+
registerArchiveRoutes,
359+
'POST',
360+
'/api/archives/:id/restore',
361+
);
362+
const restoreRes = createResponse('/api/archives/:id/restore');
363+
364+
restoreHandler(
365+
{
366+
params: { id: state[ARCHIVES_KEY][0].id },
367+
query: {},
368+
},
369+
restoreRes,
370+
);
371+
372+
expect(restoreRes.body.status).to.equal('success');
373+
expect(state[TOKENS_KEY]).to.have.length(1);
374+
expect(state[TOKENS_KEY][0].token).to.equal('legacy-duration');
375+
expect(state[TOKENS_KEY][0].mode).to.equal('duration');
376+
expect(state[TOKENS_KEY][0].expiresIn).to.equal('1d');
377+
expect(state[TOKENS_KEY][0].exp).to.be.a('number');
378+
expect(state[ARCHIVES_KEY]).to.deep.equal([]);
379+
});
380+
381+
it('restores legacy exp-only share tokens as exact-datetime shares', async function () {
382+
state[SUBS_KEY] = [
383+
{
384+
name: 'shared-sub',
385+
source: 'remote',
386+
process: [],
387+
},
388+
];
389+
const exactExp = Date.now() - 5 * 60 * 1000;
390+
state[TOKENS_KEY] = [
391+
{
392+
token: 'legacy-datetime',
393+
type: 'sub',
394+
name: 'shared-sub',
395+
displayName: 'Legacy Exact Share',
396+
exp: exactExp,
397+
},
398+
];
399+
400+
const deleteHandler = getHandler(
401+
registerTokenRoutes,
402+
'DELETE',
403+
'/api/token/:token',
404+
);
405+
const deleteRes = createResponse('/api/token/:token');
406+
407+
deleteHandler(
408+
{
409+
params: { token: 'legacy-datetime' },
410+
query: {
411+
mode: 'archive',
412+
type: 'sub',
413+
name: 'shared-sub',
414+
},
415+
},
416+
deleteRes,
417+
);
418+
419+
expect(deleteRes.body.status).to.equal('success');
420+
expect(state[TOKENS_KEY]).to.deep.equal([]);
421+
expect(state[ARCHIVES_KEY]).to.have.length(1);
422+
423+
const restoreHandler = getHandler(
424+
registerArchiveRoutes,
425+
'POST',
426+
'/api/archives/:id/restore',
427+
);
428+
const restoreRes = createResponse('/api/archives/:id/restore');
429+
430+
restoreHandler(
431+
{
432+
params: { id: state[ARCHIVES_KEY][0].id },
433+
query: {},
434+
},
435+
restoreRes,
436+
);
437+
438+
expect(restoreRes.body.status).to.equal('success');
439+
expect(state[TOKENS_KEY]).to.have.length(1);
440+
expect(state[TOKENS_KEY][0].token).to.equal('legacy-datetime');
441+
expect(state[TOKENS_KEY][0].mode).to.equal('datetime');
442+
expect(state[TOKENS_KEY][0].exp).to.equal(exactExp);
443+
expect(state[TOKENS_KEY][0]).to.not.have.property('expiresIn');
308444
expect(state[ARCHIVES_KEY]).to.deep.equal([]);
309445
});
310446

0 commit comments

Comments
 (0)