Skip to content

Commit 5bfe7be

Browse files
committed
refactor(s3): extract access link sdk core
1 parent 1351e50 commit 5bfe7be

31 files changed

Lines changed: 2047 additions & 260 deletions

.agents/design/common/s3/shortlink-sdk-wrapper.md

Lines changed: 565 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { createS3AccessLinkService } from '@fastgpt-sdk/storage/access-link';
2+
import { serviceEnv } from '../../../env';
3+
import { getNanoid } from '@fastgpt/global/common/string/tools';
4+
import {
5+
S3_ACCESS_LINK_ROUTES,
6+
S3_DOWNLOAD_ALIAS_ID_LENGTH,
7+
S3_UPLOAD_TOKEN_LENGTH
8+
} from './constants';
9+
import { mongoS3DownloadAliasStore } from './downloadAlias/store';
10+
import { mongoS3UploadSessionStore } from './uploadSession/store';
11+
12+
const endpointUrl = `${serviceEnv.FILE_DOMAIN || serviceEnv.FE_DOMAIN || ''}${serviceEnv.NEXT_PUBLIC_BASE_URL}`;
13+
14+
export const s3AccessLinkService = createS3AccessLinkService({
15+
secret: serviceEnv.FILE_TOKEN_KEY,
16+
routes: {
17+
buildDownloadUrl: (signedAlias) =>
18+
`${endpointUrl}${S3_ACCESS_LINK_ROUTES.download}/${signedAlias}`,
19+
buildUploadUrl: (token) => `${endpointUrl}${S3_ACCESS_LINK_ROUTES.upload}/${token}`
20+
},
21+
stores: {
22+
downloadAlias: mongoS3DownloadAliasStore,
23+
uploadSession: mongoS3UploadSessionStore
24+
},
25+
idGenerator: {
26+
aliasId: () => getNanoid(S3_DOWNLOAD_ALIAS_ID_LENGTH),
27+
uploadToken: () => getNanoid(S3_UPLOAD_TOKEN_LENGTH)
28+
},
29+
uploadSessionUsePolicy: 'mark-used'
30+
});
Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,14 @@
1+
export {
2+
S3_ACCESS_LINK_PURGE_GRACE_HOURS,
3+
S3_DOWNLOAD_ALIAS_ID_LENGTH,
4+
S3_DOWNLOAD_ALIAS_SIGN_VERSION,
5+
S3_DOWNLOAD_EXPIRE_BUCKET_MS,
6+
S3_DOWNLOAD_EXPIRE_BUCKET_THRESHOLD_MS,
7+
S3_DOWNLOAD_SIGNATURE_LENGTH,
8+
S3_UPLOAD_TOKEN_LENGTH
9+
} from '@fastgpt-sdk/storage/access-link';
10+
111
export const S3_ACCESS_LINK_ROUTES = {
212
download: '/api/system/file/d',
313
upload: '/api/system/file/u'
414
} as const;
5-
6-
export const S3_DOWNLOAD_ALIAS_SIGN_VERSION = 'v1';
7-
export const S3_DOWNLOAD_ALIAS_ID_LENGTH = 16;
8-
export const S3_DOWNLOAD_SIGNATURE_LENGTH = 22;
9-
export const S3_UPLOAD_TOKEN_LENGTH = 22;
10-
export const S3_ACCESS_LINK_PURGE_GRACE_HOURS = 24;
11-
12-
export const S3_DOWNLOAD_EXPIRE_BUCKET_MS = {
13-
short: 15 * 60 * 1000,
14-
medium: 60 * 60 * 1000,
15-
long: 24 * 60 * 60 * 1000
16-
} as const;
17-
18-
export const S3_DOWNLOAD_EXPIRE_BUCKET_THRESHOLD_MS = {
19-
short: 2 * 60 * 60 * 1000,
20-
medium: 24 * 60 * 60 * 1000
21-
} as const;
Lines changed: 6 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,9 @@
1-
import { addHours } from 'date-fns';
21
import {
32
CreateS3DownloadAccessUrlParamsSchema,
43
S3ProxyDownloadPayloadSchema,
54
type S3ProxyDownloadPayload
65
} from '../type';
7-
import {
8-
assertS3DownloadAliasSignature,
9-
buildS3DownloadAliasKey,
10-
buildS3DownloadUrl,
11-
encodeExpiresAtMinute,
12-
generateS3AliasId,
13-
resolveDownloadExpiresAt,
14-
signS3DownloadAlias
15-
} from '../utils';
16-
import { S3AccessLinkErrCode, S3AccessLinkError } from '../error';
17-
import { S3_ACCESS_LINK_PURGE_GRACE_HOURS } from '../constants';
18-
import {
19-
createS3DownloadAlias,
20-
disableS3DownloadAliasByAliasId,
21-
findS3DownloadAliasByAliasId,
22-
findS3DownloadAliasByAliasKey,
23-
touchS3DownloadAliasPurgeAt
24-
} from './entity';
6+
import { s3AccessLinkService } from '../accessLinkService';
257

268
/**
279
* 创建或复用下载 alias,并返回带过期时间与 HMAC 签名的短 URL。
@@ -31,42 +13,7 @@ import {
3113
*/
3214
export const createS3DownloadAccessUrl = async (params: unknown) => {
3315
const parsed = CreateS3DownloadAccessUrlParamsSchema.parse(params);
34-
const now = new Date();
35-
const expiresAt = resolveDownloadExpiresAt(parsed.expiredTime, now);
36-
const expMinute36 = encodeExpiresAtMinute(expiresAt);
37-
const purgeAt = addHours(expiresAt, S3_ACCESS_LINK_PURGE_GRACE_HOURS);
38-
const aliasKey = buildS3DownloadAliasKey(parsed);
39-
40-
const existingAlias = await findS3DownloadAliasByAliasKey(aliasKey);
41-
const alias =
42-
existingAlias ??
43-
(await createS3DownloadAlias({
44-
aliasId: generateS3AliasId(),
45-
aliasKey,
46-
bucketName: parsed.bucketName,
47-
objectKey: parsed.objectKey,
48-
filename: parsed.filename,
49-
responseContentType: parsed.responseContentType,
50-
lastIssuedAt: now,
51-
purgeAt
52-
}));
53-
54-
if (!alias) {
55-
throw new S3AccessLinkError(S3AccessLinkErrCode.downloadAliasNotFound);
56-
}
57-
58-
await touchS3DownloadAliasPurgeAt({
59-
aliasKey,
60-
purgeAt,
61-
now
62-
});
63-
64-
const sig = signS3DownloadAlias({
65-
aliasId: alias.aliasId,
66-
expMinute36
67-
});
68-
69-
return buildS3DownloadUrl(`${alias.aliasId}.${expMinute36}.${sig}`);
16+
return s3AccessLinkService.createDownloadUrl(parsed);
7017
};
7118

7219
/**
@@ -78,25 +25,11 @@ export const createS3DownloadAccessUrl = async (params: unknown) => {
7825
export const verifyS3DownloadAccess = async (
7926
signedAlias: string
8027
): Promise<S3ProxyDownloadPayload> => {
81-
const { aliasId } = assertS3DownloadAliasSignature(signedAlias);
82-
const alias = await findS3DownloadAliasByAliasId(aliasId);
83-
84-
if (!alias) {
85-
throw new S3AccessLinkError(S3AccessLinkErrCode.downloadAliasNotFound);
86-
}
87-
88-
if (alias.disabledAt) {
89-
throw new S3AccessLinkError(S3AccessLinkErrCode.downloadAliasRevoked);
90-
}
91-
92-
return S3ProxyDownloadPayloadSchema.parse({
93-
bucketName: alias.bucketName,
94-
objectKey: alias.objectKey,
95-
filename: alias.filename,
96-
responseContentType: alias.responseContentType
97-
});
28+
return S3ProxyDownloadPayloadSchema.parse(
29+
await s3AccessLinkService.verifyDownloadAlias(signedAlias)
30+
);
9831
};
9932

10033
export const revokeS3DownloadAlias = (aliasId: string) => {
101-
return disableS3DownloadAliasByAliasId(aliasId);
34+
return s3AccessLinkService.revokeDownloadAlias(aliasId);
10235
};
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import {
2+
S3AccessLinkErrCode,
3+
S3AccessLinkError,
4+
type S3DownloadAliasStore
5+
} from '@fastgpt-sdk/storage/access-link';
6+
import { MongoS3DownloadAlias } from './schema';
7+
import type { S3DownloadAliasType } from '../type';
8+
9+
const isMongoDuplicateKeyError = (error: unknown) =>
10+
!!error && typeof error === 'object' && 'code' in error && error.code === 11000;
11+
12+
const toAliasRecord = (record: S3DownloadAliasType) => record;
13+
14+
export const mongoS3DownloadAliasStore: S3DownloadAliasStore = {
15+
findByAliasKey: async (aliasKey) => {
16+
const record = await MongoS3DownloadAlias.findOne({ aliasKey }).lean();
17+
return record ? toAliasRecord(record) : null;
18+
},
19+
findByAliasId: async (aliasId) => {
20+
const record = await MongoS3DownloadAlias.findOne({ aliasId }).lean();
21+
return record ? toAliasRecord(record) : null;
22+
},
23+
create: async (data) => {
24+
try {
25+
const now = new Date();
26+
const [created] = await MongoS3DownloadAlias.create([
27+
{
28+
...data,
29+
createTime: data.createTime ?? now,
30+
updateTime: data.updateTime ?? now
31+
}
32+
]);
33+
34+
return toAliasRecord(created.toObject() as S3DownloadAliasType);
35+
} catch (error) {
36+
if (isMongoDuplicateKeyError(error)) {
37+
throw new S3AccessLinkError(S3AccessLinkErrCode.duplicateAliasKey, { cause: error });
38+
}
39+
40+
throw error;
41+
}
42+
},
43+
touchLease: async ({ aliasId, purgeAt, lastIssuedAt }) => {
44+
await MongoS3DownloadAlias.updateOne(
45+
{ aliasId },
46+
{
47+
$set: {
48+
updateTime: lastIssuedAt,
49+
lastIssuedAt
50+
},
51+
$max: {
52+
purgeAt
53+
}
54+
}
55+
);
56+
},
57+
disableByAliasId: async ({ aliasId, disabledAt }) => {
58+
await MongoS3DownloadAlias.updateOne(
59+
{ aliasId },
60+
{
61+
$set: {
62+
disabledAt,
63+
updateTime: disabledAt
64+
}
65+
}
66+
);
67+
},
68+
deleteByObject: async ({ bucketName, objectKey }) => {
69+
await MongoS3DownloadAlias.deleteMany({
70+
bucketName,
71+
objectKey
72+
});
73+
},
74+
deleteByObjects: async ({ bucketName, objectKeys }) => {
75+
if (objectKeys.length === 0) return;
76+
77+
await MongoS3DownloadAlias.deleteMany({
78+
bucketName,
79+
objectKey: { $in: objectKeys }
80+
});
81+
}
82+
};
Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,6 @@
1-
export enum S3AccessLinkErrCode {
2-
invalidSignedAlias = 'InvalidSignedAlias',
3-
expiredSignedAlias = 'ExpiredSignedAlias',
4-
invalidSignedAliasSignature = 'InvalidSignedAliasSignature',
5-
downloadAliasNotFound = 'DownloadAliasNotFound',
6-
downloadAliasRevoked = 'DownloadAliasRevoked',
7-
uploadSessionNotFound = 'UploadSessionNotFound',
8-
uploadSessionExpired = 'UploadSessionExpired',
9-
uploadSessionRevoked = 'UploadSessionRevoked'
10-
}
11-
12-
/**
13-
* 短链内部错误分类。
14-
*
15-
* 该错误只在 accessLink service/utils 与文件代理边界之间传递,不直接暴露给客户端。
16-
* API 层统一把它映射为 `unAuthFile`,避免泄露 alias 是否存在、token 是否过期等枚举信号。
17-
*/
18-
export class S3AccessLinkError extends Error {
19-
constructor(public readonly code: S3AccessLinkErrCode) {
20-
super(code);
21-
this.name = 'S3AccessLinkError';
22-
}
23-
}
24-
25-
export const isS3AccessLinkError = (error: unknown): error is S3AccessLinkError =>
26-
error instanceof S3AccessLinkError;
1+
export {
2+
S3AccessLinkErrCode,
3+
S3AccessLinkError,
4+
isS3AccessLinkError,
5+
type S3AccessLinkErrorCode
6+
} from '@fastgpt-sdk/storage/access-link';
Lines changed: 5 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,9 @@
11
import {
22
CreateS3UploadAccessUrlParamsSchema,
33
S3ProxyUploadPayloadSchema,
4-
S3UploadTokenSchema,
54
type S3ProxyUploadPayload
65
} from '../type';
7-
import { buildS3UploadUrl, generateS3UploadToken, hashS3UploadToken } from '../utils';
8-
import { S3AccessLinkErrCode, S3AccessLinkError } from '../error';
9-
import {
10-
createS3UploadSession,
11-
findS3UploadSessionByTokenHash,
12-
markS3UploadSessionUsed,
13-
revokeS3UploadSessionByTokenHash
14-
} from './entity';
6+
import { s3AccessLinkService } from '../accessLinkService';
157

168
/**
179
* 创建一次上传会话并返回短上传 URL。
@@ -21,58 +13,13 @@ import {
2113
*/
2214
export const createS3UploadAccessUrl = async (params: unknown) => {
2315
const parsed = CreateS3UploadAccessUrlParamsSchema.parse(params);
24-
const token = generateS3UploadToken();
25-
const tokenHash = hashS3UploadToken(token);
26-
27-
await createS3UploadSession({
28-
tokenHash,
29-
bucketName: parsed.bucketName,
30-
objectKey: parsed.objectKey,
31-
maxSize: parsed.maxSize,
32-
uploadConstraints: parsed.uploadConstraints,
33-
metadata: parsed.metadata,
34-
expiresAt: parsed.expiredTime
35-
});
36-
37-
return buildS3UploadUrl(token);
16+
return s3AccessLinkService.createUploadUrl(parsed);
3817
};
3918

40-
export const verifyS3UploadSessionToken = async (
41-
token: string,
42-
now = new Date()
43-
): Promise<S3ProxyUploadPayload> => {
44-
const parsedToken = S3UploadTokenSchema.safeParse(token);
45-
46-
if (!parsedToken.success) {
47-
throw new S3AccessLinkError(S3AccessLinkErrCode.uploadSessionNotFound);
48-
}
49-
50-
const tokenHash = hashS3UploadToken(parsedToken.data);
51-
const session = await findS3UploadSessionByTokenHash(tokenHash);
52-
53-
if (!session) {
54-
throw new S3AccessLinkError(S3AccessLinkErrCode.uploadSessionNotFound);
55-
}
56-
57-
if (session.revokedAt) {
58-
throw new S3AccessLinkError(S3AccessLinkErrCode.uploadSessionRevoked);
59-
}
60-
61-
if (session.expiresAt.getTime() <= now.getTime()) {
62-
throw new S3AccessLinkError(S3AccessLinkErrCode.uploadSessionExpired);
63-
}
64-
65-
await markS3UploadSessionUsed(tokenHash);
66-
67-
return S3ProxyUploadPayloadSchema.parse({
68-
bucketName: session.bucketName,
69-
objectKey: session.objectKey,
70-
maxSize: session.maxSize,
71-
uploadConstraints: session.uploadConstraints,
72-
metadata: session.metadata
73-
});
19+
export const verifyS3UploadSessionToken = async (token: string): Promise<S3ProxyUploadPayload> => {
20+
return S3ProxyUploadPayloadSchema.parse(await s3AccessLinkService.verifyUploadToken(token));
7421
};
7522

7623
export const revokeS3UploadSessionToken = (token: string) => {
77-
return revokeS3UploadSessionByTokenHash(hashS3UploadToken(token));
24+
return s3AccessLinkService.revokeUploadToken(token);
7825
};

0 commit comments

Comments
 (0)