Skip to content

Commit d8f6667

Browse files
committed
feat: 支持分享链接按次数消费并统一 token 鉴权处理(前端>=2.17.22)
1 parent 3588358 commit d8f6667

4 files changed

Lines changed: 115 additions & 22 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.23.29",
3+
"version": "2.23.30",
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
@@ -93,6 +93,8 @@ function restoreArchivedEntry(entry) {
9393
mode: snapshot.mode,
9494
expiresIn: snapshot.expiresIn,
9595
exp: snapshot.exp,
96+
count: snapshot.count,
97+
usedCount: snapshot.usedCount,
9698
});
9799
default:
98100
throw new RequestInvalidError(

backend/src/restful/index.js

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
syncArtifactItem,
1111
} from '@/restful/sync';
1212
import { gistBackupAction } from '@/restful/miscs';
13-
import { TOKENS_KEY, SETTINGS_KEY } from '@/constants';
13+
import { SETTINGS_KEY } from '@/constants';
1414
import { startArtifactCronJobs } from '@/utils/artifact-cron';
1515

1616
import registerSubscriptionRoutes from './subscriptions';
@@ -29,6 +29,7 @@ import registerMiscRoutes from './miscs';
2929
import registerNodeInfoRoutes from './node-info';
3030
import registerParserRoutes from './parser';
3131
import registerLogRoutes from './logs';
32+
import { consumeShareToken } from './token';
3233

3334
export default function serve() {
3435
let port;
@@ -74,16 +75,10 @@ export default function serve() {
7475
res.status(405).send('Method not allowed');
7576
return;
7677
}
77-
const tokens = $.read(TOKENS_KEY) || [];
78-
const token = tokens.find(
79-
(t) =>
80-
t.token === req.query.token &&
81-
(`/share/${t.type}/${t.name}` === pathname ||
82-
pathname.startsWith(
83-
`/share/${t.type}/${t.name}/`,
84-
)) &&
85-
(t.exp == null || t.exp > Date.now()),
86-
);
78+
const token = consumeShareToken({
79+
token: req.query.token,
80+
pathname,
81+
});
8782
if (token) {
8883
next();
8984
return;
@@ -401,14 +396,11 @@ export default function serve() {
401396
pathRewrite: async (path, req) => {
402397
if (req.method.toLowerCase() !== 'get')
403398
throw new Error('Method not allowed');
404-
const tokens = $.read(TOKENS_KEY) || [];
405-
const token = tokens.find(
406-
(t) =>
407-
t.token === req.query.token &&
408-
t.type === req.params.type &&
409-
t.name === req.params.name &&
410-
(t.exp == null || t.exp > Date.now()),
411-
);
399+
const token = consumeShareToken({
400+
token: req.query.token,
401+
type: req.params.type,
402+
name: req.params.name,
403+
});
412404
if (!token) {
413405
const settings = $.read(SETTINGS_KEY);
414406
if (

backend/src/restful/token.js

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ function normalizeExpirationMode(mode) {
9191
if (mode == null || mode === '') {
9292
return undefined;
9393
}
94-
if (mode === 'duration' || mode === 'datetime') {
94+
if (mode === 'duration' || mode === 'datetime' || mode === 'count') {
9595
return mode;
9696
}
9797
throw new RequestInvalidError(
@@ -171,6 +171,38 @@ function resolveDurationExpiration(options = {}, { required = false } = {}) {
171171
};
172172
}
173173

174+
function resolveCountExpiration(options = {}) {
175+
const rawCount = options?.count;
176+
if (rawCount == null || rawCount === '') {
177+
throw new RequestInvalidError(
178+
'INVALID_SHARE_COUNT',
179+
`Invalid count option: ${rawCount}`,
180+
);
181+
}
182+
183+
const count = Number(rawCount);
184+
if (!Number.isSafeInteger(count) || count <= 0) {
185+
throw new RequestInvalidError(
186+
'INVALID_SHARE_COUNT',
187+
`Invalid count option: ${rawCount}`,
188+
);
189+
}
190+
191+
const rawUsedCount = options?.usedCount ?? 0;
192+
const usedCount = Number(rawUsedCount);
193+
if (!Number.isSafeInteger(usedCount) || usedCount < 0 || usedCount > count) {
194+
throw new RequestInvalidError(
195+
'INVALID_SHARE_USED_COUNT',
196+
`Invalid usedCount option: ${rawUsedCount}`,
197+
);
198+
}
199+
200+
return {
201+
count,
202+
usedCount,
203+
};
204+
}
205+
174206
function createTokenItem(payload, options = {}) {
175207
const type = payload?.type;
176208
const name = payload?.name;
@@ -243,8 +275,11 @@ function createTokenItem(payload, options = {}) {
243275
inferLegacyExpirationMode(options);
244276
let durationExpiration = null;
245277
let exp;
278+
let countExpiration = null;
246279
if (expirationMode === 'datetime') {
247280
exp = resolveExactExpiration(options);
281+
} else if (expirationMode === 'count') {
282+
countExpiration = resolveCountExpiration(options);
248283
} else {
249284
durationExpiration = resolveDurationExpiration(options, {
250285
required: expirationMode === 'duration',
@@ -269,20 +304,29 @@ function createTokenItem(payload, options = {}) {
269304
const normalizedMode =
270305
expirationMode === 'datetime'
271306
? 'datetime'
307+
: expirationMode === 'count'
308+
? 'count'
272309
: durationExpiration
273310
? 'duration'
274311
: undefined;
275312
const safePayload = { ...payload };
276313
delete safePayload.mode;
277314
delete safePayload.exp;
278315
delete safePayload.expiresIn;
316+
delete safePayload.count;
317+
delete safePayload.usedCount;
279318
const tokenData = {
280319
...safePayload,
281320
token,
282321
createdAt: Date.now(),
283322
...(normalizedMode ? { mode: normalizedMode } : {}),
284323
...(normalizedMode === 'datetime'
285324
? { exp }
325+
: normalizedMode === 'count'
326+
? {
327+
count: countExpiration.count,
328+
usedCount: countExpiration.usedCount,
329+
}
286330
: durationExpiration
287331
? {
288332
expiresIn: durationExpiration.rawExpiresIn,
@@ -314,6 +358,61 @@ function deleteTokenItem(token, type, name) {
314358
return match;
315359
}
316360

361+
function matchesShareToken(item, { token, type, name, pathname }) {
362+
return (
363+
item.token === token &&
364+
(type ? item.type === type : true) &&
365+
(name ? item.name === name : true) &&
366+
(pathname
367+
? `/share/${item.type}/${item.name}` === pathname ||
368+
pathname.startsWith(`/share/${item.type}/${item.name}/`)
369+
: true)
370+
);
371+
}
372+
373+
function isShareTokenUsable(item) {
374+
if (item.exp != null && item.exp <= Date.now()) {
375+
return false;
376+
}
377+
378+
if (item.mode === 'count') {
379+
const count = Number(item.count);
380+
const usedCount = item.usedCount == null ? 0 : Number(item.usedCount);
381+
return (
382+
Number.isSafeInteger(count) &&
383+
count > 0 &&
384+
Number.isSafeInteger(usedCount) &&
385+
usedCount >= 0 &&
386+
usedCount < count
387+
);
388+
}
389+
390+
return true;
391+
}
392+
393+
function consumeShareToken(query) {
394+
const allTokens = $.read(TOKENS_KEY) || [];
395+
const tokenIndex = allTokens.findIndex(
396+
(item) => matchesShareToken(item, query) && isShareTokenUsable(item),
397+
);
398+
if (tokenIndex < 0) {
399+
return null;
400+
}
401+
402+
const token = allTokens[tokenIndex];
403+
if (token.mode !== 'count') {
404+
return token;
405+
}
406+
407+
const nextToken = {
408+
...token,
409+
usedCount: Number(token.usedCount ?? 0) + 1,
410+
};
411+
allTokens[tokenIndex] = nextToken;
412+
$.write(allTokens, TOKENS_KEY);
413+
return nextToken;
414+
}
415+
317416
function shouldArchiveDeletion(mode) {
318417
if (mode == null || mode === '' || mode === 'permanent') {
319418
return false;
@@ -327,4 +426,4 @@ function shouldArchiveDeletion(mode) {
327426
);
328427
}
329428

330-
export { createTokenItem, deleteTokenItem };
429+
export { createTokenItem, deleteTokenItem, consumeShareToken };

0 commit comments

Comments
 (0)