) => {
@@ -110,7 +120,11 @@ function GameCard({ game, index }: { game: (typeof GAMES)[number]; index: number
-
@@ -140,6 +154,15 @@ export default function Games() {
保留轻量试玩与静态资源检查入口,维持与整站一致的毛玻璃语言。在这里您可以快速启动站内集成的小游戏。
+
+
+ {MORE_GAMES_LABEL}
+
diff --git a/front/src/pages/Login.tsx b/front/src/pages/Login.tsx
index c97d91d..0b36dcb 100644
--- a/front/src/pages/Login.tsx
+++ b/front/src/pages/Login.tsx
@@ -331,12 +331,12 @@ export default function Login() {
value={registerPassword}
onChange={(event) => setRegisterPassword(event.target.value)}
required
- minLength={10}
+ minLength={8}
maxLength={64}
/>
- 至少 10 位,并包含大写字母、小写字母、数字和特殊字符。
+ 至少 8 位,并包含大写字母。
@@ -350,7 +350,7 @@ export default function Login() {
value={registerConfirmPassword}
onChange={(event) => setRegisterConfirmPassword(event.target.value)}
required
- minLength={10}
+ minLength={8}
maxLength={64}
/>
diff --git a/front/src/pages/games-links.test.ts b/front/src/pages/games-links.test.ts
new file mode 100644
index 0000000..474a33d
--- /dev/null
+++ b/front/src/pages/games-links.test.ts
@@ -0,0 +1,45 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ GAME_EXIT_PATH,
+ MORE_GAMES_LABEL,
+ MORE_GAMES_URL,
+ isGameId,
+ resolveGameHref,
+ resolveGamePlayerPath,
+} from './games-links';
+
+test('resolveGameHref maps the cat game to the t_race OSS page', () => {
+ assert.equal(resolveGameHref('cat'), '/t_race/');
+});
+
+test('resolveGameHref maps the race game to the race OSS page', () => {
+ assert.equal(resolveGameHref('race'), '/race/');
+});
+
+test('resolveGamePlayerPath maps the cat game to the in-app player route', () => {
+ assert.equal(resolveGamePlayerPath('cat'), '/games/cat');
+});
+
+test('resolveGamePlayerPath maps the race game to the in-app player route', () => {
+ assert.equal(resolveGamePlayerPath('race'), '/games/race');
+});
+
+test('isGameId only accepts supported game ids', () => {
+ assert.equal(isGameId('cat'), true);
+ assert.equal(isGameId('race'), true);
+ assert.equal(isGameId('t_race'), false);
+});
+
+test('GAME_EXIT_PATH points back to the games lobby', () => {
+ assert.equal(GAME_EXIT_PATH, '/games');
+});
+
+test('MORE_GAMES_URL points to the requested external games site', () => {
+ assert.equal(MORE_GAMES_URL, 'https://quruifps.xyz');
+});
+
+test('MORE_GAMES_LABEL keeps the friendly-link copy', () => {
+ assert.equal(MORE_GAMES_LABEL, '更多游戏请访问quruifps.xyz');
+});
diff --git a/front/src/pages/games-links.ts b/front/src/pages/games-links.ts
new file mode 100644
index 0000000..340f024
--- /dev/null
+++ b/front/src/pages/games-links.ts
@@ -0,0 +1,21 @@
+const GAME_HREFS = {
+ cat: '/t_race/',
+ race: '/race/',
+} as const;
+
+export type GameId = keyof typeof GAME_HREFS;
+export const GAME_EXIT_PATH = '/games';
+export const MORE_GAMES_URL = 'https://quruifps.xyz';
+export const MORE_GAMES_LABEL = '更多游戏请访问quruifps.xyz';
+
+export function resolveGameHref(gameId: GameId) {
+ return GAME_HREFS[gameId];
+}
+
+export function resolveGamePlayerPath(gameId: GameId) {
+ return `${GAME_EXIT_PATH}/${gameId}`;
+}
+
+export function isGameId(value: string): value is GameId {
+ return value in GAME_HREFS;
+}
diff --git a/memory.md b/memory.md
index 9ef19fd..4c99d34 100644
--- a/memory.md
+++ b/memory.md
@@ -8,13 +8,16 @@
- 网盘已支持上传、下载、重命名、删除、移动、复制、公开分享、接收快传后存入
- 注册改成邀请码机制,邀请码单次使用后自动刷新,并在管理台展示与复制
- 同账号仅允许一台设备同时登录,旧设备会在下一次访问受保护接口时失效
- - 后端已补生产 CORS,默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz`,并已重新发布
- - 线上后端文件存储已从旧东京 OSS 桶切换到成都新桶 `yoyuzh-files2`,并已完成对象级存在性验证
+ - 后端已补生产 CORS,默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz`
+ - 线上文件存储与前端静态托管已迁到多吉云对象存储,后端通过临时密钥 API 获取短期 S3 会话访问底层 COS 兼容桶
+ - 管理台 dashboard 已显示总存储量、下载流量、今日请求次数、快传使用量、离线快传占用和请求折线图,并支持调整离线快传总上限
+ - 管理台用户列表已显示每个用户的已用空间 / 配额,表格也已收紧
+ - 游戏页已接入 `/race/`、`/t_race/`,带站内播放器、退出按钮和友情链接
+ - 2026-04-02 已统一密码策略为“至少 8 位且包含大写字母”,并补测试确认管理员改密后旧密码失效、新密码生效
- 根目录 README 已重写为中文公开版 GitHub 风格
- VS Code 工作区已补 `.vscode/settings.json`、`.vscode/extensions.json`、`lombok.config`,并在 `backend/pom.xml` 显式声明了 Lombok annotation processor
- 进行中:
- 继续观察 VS Code Java/Lombok 误报是否完全消失
- - 继续排查 `api.yoyuzh.xyz` 在不同网络/设备下的 TLS/SNI 链路稳定性
- 后续如果再做 README/开源化展示,可以继续补 banner、截图和架构图
- 待开始:
- 如果用户继续提需求,优先沿当前网站主线迭代,不再回到旧教务方向
@@ -28,16 +31,16 @@
| 网盘侧边栏改成单一树状目录结构 | 更像真实网盘,层级关系清晰 | 保留“快速访问 + 目录”双区块: 结构割裂 |
| 注册邀请码改成单次使用后自动刷新 | 更适合私域邀请式注册,管理台也能直接查看当前邀请码 | 固定邀请码: 容易扩散且不可控 |
| 单设备登录通过“用户当前会话 ID + JWT sid claim”实现 | 新登录能立即顶掉旧 access token,而不仅仅是旧 refresh token | 只撤销 refresh token: 旧 access token 仍会继续有效一段时间 |
-| 前端发布继续使用 `node scripts/deploy-front-oss.mjs` | 仓库已有正式 OSS 发布脚本,流程稳定 | 手动上传 OSS: 容易出错,也不利于复用 |
+| 前端发布继续使用 `node scripts/deploy-front-oss.mjs` | 仓库已有正式静态站发布脚本,现已切到多吉云临时密钥 + S3 兼容上传流程 | 手动上传对象存储: 容易出错,也不利于复用 |
| 后端发布继续采用“本地打包 + SSH/ SCP 上传 jar + systemd 重启” | 当前线上就按这个方式运行 | 自创部署脚本: 仓库里没有现成正式脚本,容易和现网偏离 |
-| 主站 CORS 默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz` | 前端生产环境托管在 OSS 域名下,必须允许主站跨域调用后端 API | 仅保留 localhost: 会导致生产站调用 API 时被浏览器拦截 |
-| 线上网盘文件桶切到成都 `yoyuzh-files2` | 现有普通文件下载主链路是浏览器直连 OSS,主要性能瓶颈在对象存储地域与公网链路 | 继续使用东京桶: 中国内地用户下载链路更长,难以直接改善速度 |
+| 主站 CORS 默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz` | 前端生产环境托管在独立静态站域名下,必须允许主站跨域调用后端 API | 仅保留 localhost: 会导致生产站调用 API 时被浏览器拦截 |
+| 文件存储切到多吉云对象存储并使用临时密钥 | 后端、前端发布和迁移脚本都可统一走 S3 兼容协议,同时减少长期静态密钥暴露 | 继续使用阿里云 OSS 固定密钥: 已不符合当前多吉云接入方式 |
+| 密码策略放宽到“至少 8 位且包含大写字母” | 降低注册和管理员改密阻力,同时保留最基础的复杂度门槛 | 继续要求大小写 + 数字 + 特殊字符: 对当前站点用户而言过重,且已导致后台改密体验不一致 |
## 待解决问题
- [ ] VS Code 若仍报 `final 字段未在构造器初始化` 之类错误,优先判断为 Lombok / Java Language Server 误报,而不是源码真实错误
- [ ] `front/README.md` 仍是旧模板风格说明,当前真实入口说明以根目录 `README.md` 为准,后续可继续整理
- [ ] 前端构建仍有 chunk size warning,目前不阻塞发布,但后续可以考虑做更细的拆包
-- [ ] `api.yoyuzh.xyz` 仍存在“同机房 IP 直连可用,但带域名 TLS/SNI 有时失败”的链路问题;这不是后端业务代码错误
- [ ] 线上前端 bundle 当前仍内嵌 `https://api.yoyuzh.xyz/api`,API 子域名异常时会直接表现为“网络异常/登录失败”
## 关键约束
@@ -50,11 +53,9 @@
- 已知线上后端运行包路径是 `/opt/yoyuzh/yoyuzh-portal-backend.jar`
- 已知新服务器公网 IP 是 `1.14.49.201`
- 已知线上后端额外配置文件是 `/opt/yoyuzh/application-prod.yml`,环境变量文件是 `/opt/yoyuzh/app.env`
-- 2026-03-24 已将线上 OSS 文件存储切换到 `https://oss-cn-chengdu.aliyuncs.com` + `yoyuzh-files2`
-- 2026-03-24 已为线上配置文件创建备份:`/opt/yoyuzh/app.env.bak-before-chengdu`、`/opt/yoyuzh/application-prod.yml.bak-before-chengdu`
-- 2026-03-23 排障确认:`api.yoyuzh.xyz` 在部分网络下存在 TLS/SNI 握手异常,但后端服务与 nginx 正常,且 IP 直连加 `Host: api.yoyuzh.xyz` 时可正常返回
-- 2026-03-23 实时日志确认:Mac 端 `202.202.9.243` 登录链路 `OPTIONS /api/auth/login -> POST /api/auth/login -> 后续 /api/*` 全部返回 200;手机失败时并不总能在服务端日志中看到对应登录请求
-- 2026-03-24 线上 smoke 验证:`https://api.yoyuzh.xyz/swagger-ui.html` 返回 302,`my-site-api.service` 重启后为 active;抽样对象 `users/6/第四组 脑机接口与脑启发计算.pptx` 在新桶 HEAD 返回 200
+- 2026-04-01 已将线上文件桶与前端桶切到多吉云对象存储,后端配置走多吉云临时密钥 API
+- 2026-04-02 部署验证:`http://yoyuzh.xyz/` 返回 200,`https://yoyuzh.xyz/` 返回 200,`https://api.yoyuzh.xyz/swagger-ui.html` 最终返回 200,前端资源 `https://yoyuzh.xyz/assets/AdminApp-C9j3tmPO.js` 返回 200
+- 2026-04-02 后端服务重启后为 active,启动时间为 `2026-04-02 12:14:25 CST`
- 服务器登录信息保存在本地 `账号密码.txt`,不要把内容写进文档或对外输出
## 参考资料
@@ -69,6 +70,8 @@
- JWT 会话校验: `backend/src/main/java/com/yoyuzh/auth/JwtTokenProvider.java`
- JWT 过滤器: `backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java`
- CORS 配置: `backend/src/main/java/com/yoyuzh/config/CorsProperties.java`、`backend/src/main/resources/application.yml`
+ - 密码策略: `backend/src/main/java/com/yoyuzh/auth/PasswordPolicy.java`
- 网盘树状目录: `front/src/pages/Files.tsx`、`front/src/pages/files-tree.ts`
- 快传接收页: `front/src/pages/TransferReceive.tsx`
+ - 管理员改密接口: `backend/src/main/java/com/yoyuzh/admin/AdminService.java`
- 前端生产 API 基址: `front/.env.production`
diff --git a/scripts/deploy-front-oss.mjs b/scripts/deploy-front-oss.mjs
index 23df9eb..ce5b481 100755
--- a/scripts/deploy-front-oss.mjs
+++ b/scripts/deploy-front-oss.mjs
@@ -7,7 +7,7 @@ import {spawnSync} from 'node:child_process';
import {
buildObjectKey,
- createAuthorizationHeader,
+ createAwsV4Headers,
encodeObjectKey,
getFrontendSpaAliasContentType,
getFrontendSpaAliasKeys,
@@ -16,6 +16,7 @@ import {
listFiles,
normalizeEndpoint,
parseSimpleEnv,
+ requestDogeCloudTemporaryS3Session,
} from './oss-deploy-lib.mjs';
const repoRoot = process.cwd();
@@ -72,34 +73,39 @@ function runBuild() {
async function uploadFile({
bucket,
endpoint,
+ region,
objectKey,
filePath,
contentTypeOverride,
accessKeyId,
- accessKeySecret,
+ secretAccessKey,
+ sessionToken,
}) {
const body = await fs.readFile(filePath);
const contentType = contentTypeOverride || getContentType(objectKey);
- const date = new Date().toUTCString();
+ const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
const url = `https://${bucket}.${normalizeEndpoint(endpoint)}/${encodeObjectKey(objectKey)}`;
- const authorization = createAuthorizationHeader({
+ const signatureHeaders = createAwsV4Headers({
method: 'PUT',
+ endpoint,
+ region,
bucket,
objectKey,
- contentType,
- date,
+ headers: {
+ 'Content-Type': contentType,
+ },
+ amzDate,
accessKeyId,
- accessKeySecret,
+ secretAccessKey,
+ sessionToken,
});
const response = await fetch(url, {
method: 'PUT',
headers: {
- Authorization: authorization,
+ ...signatureHeaders,
'Cache-Control': getCacheControl(objectKey),
'Content-Length': String(body.byteLength),
- 'Content-Type': contentType,
- Date: date,
},
body,
});
@@ -113,9 +119,11 @@ async function uploadFile({
async function uploadSpaAliases({
bucket,
endpoint,
+ region,
distIndexPath,
accessKeyId,
- accessKeySecret,
+ secretAccessKey,
+ sessionToken,
remotePrefix,
dryRun,
}) {
@@ -133,11 +141,13 @@ async function uploadSpaAliases({
await uploadFile({
bucket,
endpoint,
+ region,
objectKey,
filePath: distIndexPath,
contentTypeOverride: contentType,
accessKeyId,
- accessKeySecret,
+ secretAccessKey,
+ sessionToken,
});
console.log(`uploaded alias ${objectKey}`);
}
@@ -148,11 +158,26 @@ async function main() {
await loadEnvFileIfPresent();
- const accessKeyId = requireEnv('YOYUZH_OSS_ACCESS_KEY_ID');
- const accessKeySecret = requireEnv('YOYUZH_OSS_ACCESS_KEY_SECRET');
- const endpoint = requireEnv('YOYUZH_OSS_ENDPOINT');
- const bucket = requireEnv('YOYUZH_OSS_BUCKET');
- const remotePrefix = process.env.YOYUZH_OSS_PREFIX || '';
+ const apiAccessKey = requireEnv('YOYUZH_DOGECLOUD_API_ACCESS_KEY');
+ const apiSecretKey = requireEnv('YOYUZH_DOGECLOUD_API_SECRET_KEY');
+ const scope = requireEnv('YOYUZH_DOGECLOUD_FRONT_SCOPE');
+ const apiBaseUrl = process.env.YOYUZH_DOGECLOUD_API_BASE_URL || 'https://api.dogecloud.com';
+ const region = process.env.YOYUZH_DOGECLOUD_S3_REGION || 'automatic';
+ const remotePrefix = process.env.YOYUZH_DOGECLOUD_FRONT_PREFIX || '';
+ const ttlSeconds = Number(process.env.YOYUZH_DOGECLOUD_FRONT_TTL_SECONDS || '3600');
+ const {
+ accessKeyId,
+ secretAccessKey,
+ sessionToken,
+ endpoint,
+ bucket,
+ } = await requestDogeCloudTemporaryS3Session({
+ apiBaseUrl,
+ accessKey: apiAccessKey,
+ secretKey: apiSecretKey,
+ scope,
+ ttlSeconds,
+ });
if (!skipBuild) {
runBuild();
@@ -175,10 +200,12 @@ async function main() {
await uploadFile({
bucket,
endpoint,
+ region,
objectKey,
filePath,
accessKeyId,
- accessKeySecret,
+ secretAccessKey,
+ sessionToken,
});
console.log(`uploaded ${objectKey}`);
}
@@ -186,9 +213,11 @@ async function main() {
await uploadSpaAliases({
bucket,
endpoint,
+ region,
distIndexPath: path.join(distDir, 'index.html'),
accessKeyId,
- accessKeySecret,
+ secretAccessKey,
+ sessionToken,
remotePrefix,
dryRun,
});
diff --git a/scripts/migrate-aliyun-oss-to-s3.mjs b/scripts/migrate-aliyun-oss-to-s3.mjs
new file mode 100644
index 0000000..cf66875
--- /dev/null
+++ b/scripts/migrate-aliyun-oss-to-s3.mjs
@@ -0,0 +1,402 @@
+import crypto from 'node:crypto';
+import https from 'node:https';
+import {pathToFileURL} from 'node:url';
+
+import {
+ createAwsV4Headers,
+ encodeObjectKey,
+ normalizeEndpoint,
+ requestDogeCloudTemporaryS3Session,
+} from './oss-deploy-lib.mjs';
+
+const DEFAULTS = {
+ sourceEndpoint: 'https://oss-ap-northeast-1.aliyuncs.com',
+ targetEndpoint: 'https://cos.ap-chengdu.myqcloud.com',
+ targetRegion: 'automatic',
+ prefix: '',
+ dryRun: false,
+ overwrite: false,
+};
+
+export function parseArgs(argv) {
+ const options = {...DEFAULTS};
+
+ for (const arg of argv) {
+ if (arg === '--dry-run') {
+ options.dryRun = true;
+ continue;
+ }
+
+ if (arg === '--overwrite') {
+ options.overwrite = true;
+ continue;
+ }
+
+ if (arg.startsWith('--prefix=')) {
+ options.prefix = arg.slice('--prefix='.length);
+ continue;
+ }
+
+ if (arg.startsWith('--source-endpoint=')) {
+ options.sourceEndpoint = arg.slice('--source-endpoint='.length);
+ continue;
+ }
+
+ if (arg.startsWith('--source-bucket=')) {
+ options.sourceBucket = arg.slice('--source-bucket='.length);
+ continue;
+ }
+
+ if (arg.startsWith('--source-access-key-id=')) {
+ options.sourceAccessKeyId = arg.slice('--source-access-key-id='.length);
+ continue;
+ }
+
+ if (arg.startsWith('--source-access-key-secret=')) {
+ options.sourceAccessKeySecret = arg.slice('--source-access-key-secret='.length);
+ continue;
+ }
+
+ if (arg.startsWith('--target-api-base-url=')) {
+ options.targetApiBaseUrl = arg.slice('--target-api-base-url='.length);
+ continue;
+ }
+
+ if (arg.startsWith('--target-region=')) {
+ options.targetRegion = arg.slice('--target-region='.length);
+ continue;
+ }
+
+ if (arg.startsWith('--target-scope=')) {
+ options.targetScope = arg.slice('--target-scope='.length);
+ continue;
+ }
+
+ if (arg.startsWith('--target-api-access-key=')) {
+ options.targetApiAccessKey = arg.slice('--target-api-access-key='.length);
+ continue;
+ }
+
+ if (arg.startsWith('--target-api-secret-key=')) {
+ options.targetApiSecretKey = arg.slice('--target-api-secret-key='.length);
+ continue;
+ }
+
+ if (arg.startsWith('--target-ttl-seconds=')) {
+ options.targetTtlSeconds = Number(arg.slice('--target-ttl-seconds='.length));
+ continue;
+ }
+
+ throw new Error(`Unknown argument: ${arg}`);
+ }
+
+ return options;
+}
+
+export function pickTransferredHeaders(sourceHeaders) {
+ const forwardedHeaders = {};
+ const supportedHeaders = [
+ 'cache-control',
+ 'content-disposition',
+ 'content-encoding',
+ 'content-type',
+ ];
+
+ for (const headerName of supportedHeaders) {
+ const value = sourceHeaders[headerName];
+ if (typeof value === 'string' && value) {
+ forwardedHeaders[headerName === 'content-type' ? 'Content-Type' : headerName] = value;
+ }
+ }
+
+ return forwardedHeaders;
+}
+
+function requireOption(options, key) {
+ const value = options[key];
+ if (!value) {
+ throw new Error(`Missing required option: ${key}`);
+ }
+ return value;
+}
+
+function createOssAuthorizationHeader({
+ method,
+ bucket,
+ objectKey,
+ contentType,
+ date,
+ accessKeyId,
+ accessKeySecret,
+}) {
+ const stringToSign = [
+ method.toUpperCase(),
+ '',
+ contentType,
+ date,
+ `/${bucket}/${objectKey}`,
+ ].join('\n');
+
+ const signature = crypto
+ .createHmac('sha1', accessKeySecret)
+ .update(stringToSign)
+ .digest('base64');
+ return `OSS ${accessKeyId}:${signature}`;
+}
+
+function sourceRequest({method, endpoint, bucket, objectKey, accessKeyId, accessKeySecret, query = ''}) {
+ return new Promise((resolve, reject) => {
+ const normalizedEndpoint = normalizeEndpoint(endpoint);
+ const date = new Date().toUTCString();
+ const authorization = createOssAuthorizationHeader({
+ method,
+ bucket,
+ objectKey,
+ contentType: '',
+ date,
+ accessKeyId,
+ accessKeySecret,
+ });
+
+ const request = https.request({
+ hostname: `${bucket}.${normalizedEndpoint}`,
+ path: `/${encodeObjectKey(objectKey)}${query ? `?${query}` : ''}`,
+ method,
+ headers: {
+ Authorization: authorization,
+ Date: date,
+ },
+ }, (response) => {
+ const chunks = [];
+ response.on('data', (chunk) => {
+ chunks.push(chunk);
+ });
+ response.on('end', () => {
+ resolve({
+ statusCode: response.statusCode ?? 500,
+ headers: response.headers,
+ body: Buffer.concat(chunks),
+ });
+ });
+ });
+
+ request.on('error', reject);
+ request.end();
+ });
+}
+
+function targetRequest({
+ method,
+ endpoint,
+ region,
+ bucket,
+ objectKey,
+ accessKeyId,
+ secretAccessKey,
+ sessionToken,
+ query = '',
+ headers = {},
+ body,
+}) {
+ return new Promise((resolve, reject) => {
+ const normalizedEndpoint = normalizeEndpoint(endpoint);
+ const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
+ const signedHeaders = createAwsV4Headers({
+ method,
+ endpoint,
+ region,
+ bucket,
+ objectKey,
+ query,
+ headers,
+ amzDate,
+ accessKeyId,
+ secretAccessKey,
+ sessionToken,
+ });
+
+ const request = https.request({
+ hostname: `${bucket}.${normalizedEndpoint}`,
+ path: `/${encodeObjectKey(objectKey)}${query ? `?${query}` : ''}`,
+ method,
+ headers: signedHeaders,
+ }, (response) => {
+ const chunks = [];
+ response.on('data', (chunk) => {
+ chunks.push(chunk);
+ });
+ response.on('end', () => {
+ resolve({
+ statusCode: response.statusCode ?? 500,
+ headers: response.headers,
+ body: Buffer.concat(chunks),
+ });
+ });
+ });
+
+ request.on('error', reject);
+ if (body) {
+ request.end(body);
+ return;
+ }
+ request.end();
+ });
+}
+
+function extractXmlValues(xmlBuffer, tagName) {
+ const xml = xmlBuffer.toString('utf8');
+ const pattern = new RegExp(`<${tagName}>(.*?)${tagName}>`, 'g');
+ return [...xml.matchAll(pattern)].map((match) => match[1]);
+}
+
+async function listSourceObjects(context, prefix) {
+ const keys = [];
+ let continuationToken = '';
+
+ while (true) {
+ const query = new URLSearchParams({
+ 'list-type': '2',
+ 'max-keys': '1000',
+ prefix,
+ });
+ if (continuationToken) {
+ query.set('continuation-token', continuationToken);
+ }
+
+ const response = await sourceRequest({
+ ...context,
+ method: 'GET',
+ objectKey: '',
+ query: query.toString(),
+ });
+ if (response.statusCode < 200 || response.statusCode >= 300) {
+ throw new Error(`List failed for prefix "${prefix}": ${response.statusCode} ${response.body.toString('utf8')}`);
+ }
+
+ keys.push(...extractXmlValues(response.body, 'Key'));
+ const truncated = extractXmlValues(response.body, 'IsTruncated')[0] === 'true';
+ continuationToken = extractXmlValues(response.body, 'NextContinuationToken')[0] || '';
+ if (!truncated || !continuationToken) {
+ return keys;
+ }
+ }
+}
+
+async function targetObjectExists(context, objectKey) {
+ const response = await targetRequest({
+ ...context,
+ method: 'HEAD',
+ objectKey,
+ });
+ return response.statusCode >= 200 && response.statusCode < 300;
+}
+
+async function copyObject(context, objectKey) {
+ const sourceResponse = await sourceRequest({
+ endpoint: context.sourceEndpoint,
+ bucket: context.sourceBucket,
+ accessKeyId: context.sourceAccessKeyId,
+ accessKeySecret: context.sourceAccessKeySecret,
+ method: 'GET',
+ objectKey,
+ });
+ if (sourceResponse.statusCode < 200 || sourceResponse.statusCode >= 300) {
+ throw new Error(`Download failed for ${objectKey}: ${sourceResponse.statusCode} ${sourceResponse.body.toString('utf8')}`);
+ }
+
+ const forwardedHeaders = pickTransferredHeaders(sourceResponse.headers);
+ const response = await targetRequest({
+ endpoint: context.targetEndpoint,
+ region: context.targetRegion,
+ bucket: context.targetBucket,
+ accessKeyId: context.targetAccessKeyId,
+ secretAccessKey: context.targetSecretAccessKey,
+ sessionToken: context.targetSessionToken,
+ method: 'PUT',
+ objectKey,
+ headers: {
+ ...forwardedHeaders,
+ 'Content-Length': String(sourceResponse.body.byteLength),
+ },
+ body: sourceResponse.body,
+ });
+ if (response.statusCode < 200 || response.statusCode >= 300) {
+ throw new Error(`Upload failed for ${objectKey}: ${response.statusCode} ${response.body.toString('utf8')}`);
+ }
+}
+
+async function main() {
+ const options = parseArgs(process.argv.slice(2));
+ const targetSession = await requestDogeCloudTemporaryS3Session({
+ apiBaseUrl: options.targetApiBaseUrl || 'https://api.dogecloud.com',
+ accessKey: requireOption(options, 'targetApiAccessKey'),
+ secretKey: requireOption(options, 'targetApiSecretKey'),
+ scope: requireOption(options, 'targetScope'),
+ ttlSeconds: options.targetTtlSeconds || 3600,
+ });
+ const context = {
+ sourceEndpoint: options.sourceEndpoint,
+ sourceBucket: requireOption(options, 'sourceBucket'),
+ sourceAccessKeyId: requireOption(options, 'sourceAccessKeyId'),
+ sourceAccessKeySecret: requireOption(options, 'sourceAccessKeySecret'),
+ targetEndpoint: targetSession.endpoint,
+ targetRegion: options.targetRegion,
+ targetBucket: targetSession.bucket,
+ targetAccessKeyId: targetSession.accessKeyId,
+ targetSecretAccessKey: targetSession.secretAccessKey,
+ targetSessionToken: targetSession.sessionToken,
+ };
+
+ const keys = await listSourceObjects({
+ endpoint: context.sourceEndpoint,
+ bucket: context.sourceBucket,
+ accessKeyId: context.sourceAccessKeyId,
+ accessKeySecret: context.sourceAccessKeySecret,
+ }, options.prefix);
+
+ const summary = {
+ listed: keys.length,
+ copied: 0,
+ skippedExisting: 0,
+ failed: 0,
+ };
+
+ for (const objectKey of keys) {
+ if (!options.overwrite && await targetObjectExists({
+ endpoint: context.targetEndpoint,
+ region: context.targetRegion,
+ bucket: context.targetBucket,
+ accessKeyId: context.targetAccessKeyId,
+ secretAccessKey: context.targetSecretAccessKey,
+ sessionToken: context.targetSessionToken,
+ }, objectKey)) {
+ summary.skippedExisting += 1;
+ console.log(`[skip] ${objectKey}`);
+ continue;
+ }
+
+ console.log(`${options.dryRun ? '[dry-run]' : '[copy]'} ${objectKey}`);
+ if (options.dryRun) {
+ summary.copied += 1;
+ continue;
+ }
+
+ try {
+ await copyObject(context, objectKey);
+ summary.copied += 1;
+ } catch (error) {
+ summary.failed += 1;
+ console.error(`[failed] ${objectKey}: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+
+ console.log('\nSummary');
+ console.log(JSON.stringify(summary, null, 2));
+}
+
+if (import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((error) => {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exitCode = 1;
+ });
+}
diff --git a/scripts/migrate-aliyun-oss-to-s3.test.mjs b/scripts/migrate-aliyun-oss-to-s3.test.mjs
new file mode 100644
index 0000000..2de28be
--- /dev/null
+++ b/scripts/migrate-aliyun-oss-to-s3.test.mjs
@@ -0,0 +1,38 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {parseArgs, pickTransferredHeaders} from './migrate-aliyun-oss-to-s3.mjs';
+
+test('parseArgs keeps migration flags and bucket options', () => {
+ const options = parseArgs([
+ '--dry-run',
+ '--overwrite',
+ '--prefix=games/',
+ '--source-bucket=aliyun-front',
+ '--target-scope=yoyuzh-front',
+ '--target-api-access-key=doge-ak',
+ ]);
+
+ assert.equal(options.dryRun, true);
+ assert.equal(options.overwrite, true);
+ assert.equal(options.prefix, 'games/');
+ assert.equal(options.sourceBucket, 'aliyun-front');
+ assert.equal(options.targetScope, 'yoyuzh-front');
+ assert.equal(options.targetApiAccessKey, 'doge-ak');
+});
+
+test('pickTransferredHeaders preserves only safe object metadata headers', () => {
+ const headers = pickTransferredHeaders({
+ 'cache-control': 'public,max-age=31536000,immutable',
+ 'content-type': 'text/javascript; charset=utf-8',
+ 'content-disposition': 'attachment; filename=test.js',
+ etag: '"demo"',
+ server: 'OSS',
+ });
+
+ assert.deepEqual(headers, {
+ 'cache-control': 'public,max-age=31536000,immutable',
+ 'Content-Type': 'text/javascript; charset=utf-8',
+ 'content-disposition': 'attachment; filename=test.js',
+ });
+});
diff --git a/scripts/migrate-file-storage-to-oss.mjs b/scripts/migrate-file-storage-to-oss.mjs
index d8f547c..035d15b 100644
--- a/scripts/migrate-file-storage-to-oss.mjs
+++ b/scripts/migrate-file-storage-to-oss.mjs
@@ -3,12 +3,13 @@ import {constants as fsConstants} from 'node:fs';
import {spawn} from 'node:child_process';
import https from 'node:https';
import path from 'node:path';
-import crypto from 'node:crypto';
import {
+ createAwsV4Headers,
normalizeEndpoint,
parseSimpleEnv,
encodeObjectKey,
+ requestDogeCloudTemporaryS3Session,
} from './oss-deploy-lib.mjs';
const DEFAULTS = {
@@ -16,7 +17,8 @@ const DEFAULTS = {
storageRoot: '/opt/yoyuzh/storage',
database: 'yoyuzh_portal',
bucket: 'yoyuzh-files',
- endpoint: 'https://oss-ap-northeast-1.aliyuncs.com',
+ endpoint: 'https://cos.ap-chengdu.myqcloud.com',
+ region: 'automatic',
};
function parseArgs(argv) {
@@ -114,37 +116,6 @@ function runCommand(command, args) {
});
}
-function createOssAuthorizationHeader({
- method,
- bucket,
- objectKey,
- contentType,
- date,
- accessKeyId,
- accessKeySecret,
- headers = {},
-}) {
- const canonicalizedHeaders = Object.entries(headers)
- .map(([key, value]) => [key.toLowerCase().trim(), String(value).trim()])
- .filter(([key]) => key.startsWith('x-oss-'))
- .sort(([left], [right]) => left.localeCompare(right))
- .map(([key, value]) => `${key}:${value}\n`)
- .join('');
- const canonicalizedResource = `/${bucket}/${objectKey}`;
- const stringToSign = [
- method.toUpperCase(),
- '',
- contentType,
- date,
- `${canonicalizedHeaders}${canonicalizedResource}`,
- ].join('\n');
- const signature = crypto
- .createHmac('sha1', accessKeySecret)
- .update(stringToSign)
- .digest('base64');
- return `OSS ${accessKeyId}:${signature}`;
-}
-
async function readAppEnv(appEnvPath) {
const raw = await fs.readFile(appEnvPath, 'utf8');
return parseSimpleEnv(raw);
@@ -178,20 +149,34 @@ async function queryFiles(database) {
});
}
-function ossRequest({method, endpoint, bucket, objectKey, accessKeyId, accessKeySecret, headers = {}, query = '', body}) {
+function s3Request({
+ method,
+ endpoint,
+ region,
+ bucket,
+ objectKey,
+ accessKeyId,
+ secretAccessKey,
+ sessionToken,
+ headers = {},
+ query = '',
+ body,
+}) {
return new Promise((resolve, reject) => {
const normalizedEndpoint = normalizeEndpoint(endpoint);
- const date = new Date().toUTCString();
- const contentType = headers['Content-Type'] || headers['content-type'] || '';
- const auth = createOssAuthorizationHeader({
+ const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
+ const signatureHeaders = createAwsV4Headers({
method,
+ endpoint,
+ region,
bucket,
objectKey,
- contentType,
- date,
- accessKeyId,
- accessKeySecret,
+ query,
headers,
+ amzDate,
+ accessKeyId,
+ secretAccessKey,
+ sessionToken,
});
const request = https.request({
@@ -199,9 +184,7 @@ function ossRequest({method, endpoint, bucket, objectKey, accessKeyId, accessKey
path: `/${encodeObjectKey(objectKey)}${query ? `?${query}` : ''}`,
method,
headers: {
- Date: date,
- Authorization: auth,
- ...headers,
+ ...signatureHeaders,
},
}, (response) => {
let data = '';
@@ -229,7 +212,7 @@ function ossRequest({method, endpoint, bucket, objectKey, accessKeyId, accessKey
}
async function objectExists(context, objectKey) {
- const response = await ossRequest({
+ const response = await s3Request({
...context,
method: 'HEAD',
objectKey,
@@ -243,7 +226,7 @@ async function uploadLocalFile(context, objectKey, absolutePath, contentType = '
const stat = await fileHandle.stat();
try {
- const response = await ossRequest({
+ const response = await s3Request({
...context,
method: 'PUT',
objectKey,
@@ -263,12 +246,12 @@ async function uploadLocalFile(context, objectKey, absolutePath, contentType = '
}
async function copyObject(context, sourceKey, targetKey) {
- const response = await ossRequest({
+ const response = await s3Request({
...context,
method: 'PUT',
objectKey: targetKey,
headers: {
- 'x-oss-copy-source': `/${context.bucket}/${encodeObjectKey(sourceKey)}`,
+ 'x-amz-copy-source': `/${context.bucket}/${encodeObjectKey(sourceKey)}`,
},
});
@@ -278,7 +261,7 @@ async function copyObject(context, sourceKey, targetKey) {
}
async function deleteObject(context, objectKey) {
- const response = await ossRequest({
+ const response = await s3Request({
...context,
method: 'DELETE',
objectKey,
@@ -318,7 +301,7 @@ async function listObjects(context, prefix) {
query.set('continuation-token', continuationToken);
}
- const response = await ossRequest({
+ const response = await s3Request({
...context,
method: 'GET',
objectKey: '',
@@ -370,21 +353,38 @@ async function buildArchivedObjectMap(context, files) {
async function main() {
const options = parseArgs(process.argv.slice(2));
const appEnv = await readAppEnv(options.appEnvPath);
- const endpoint = appEnv.YOYUZH_OSS_ENDPOINT || DEFAULTS.endpoint;
- const bucket = options.bucket;
- const accessKeyId = appEnv.YOYUZH_OSS_ACCESS_KEY_ID;
- const accessKeySecret = appEnv.YOYUZH_OSS_ACCESS_KEY_SECRET;
+ const apiAccessKey = appEnv.YOYUZH_DOGECLOUD_API_ACCESS_KEY;
+ const apiSecretKey = appEnv.YOYUZH_DOGECLOUD_API_SECRET_KEY;
+ const scope = appEnv.YOYUZH_DOGECLOUD_STORAGE_SCOPE || options.bucket;
+ const apiBaseUrl = appEnv.YOYUZH_DOGECLOUD_API_BASE_URL || 'https://api.dogecloud.com';
+ const region = appEnv.YOYUZH_DOGECLOUD_S3_REGION || DEFAULTS.region;
- if (!accessKeyId || !accessKeySecret) {
- throw new Error('Missing OSS credentials in app env');
+ if (!apiAccessKey || !apiSecretKey || !scope) {
+ throw new Error('Missing DogeCloud storage configuration in app env');
}
+ const {
+ accessKeyId,
+ secretAccessKey,
+ sessionToken,
+ endpoint,
+ bucket,
+ } = await requestDogeCloudTemporaryS3Session({
+ apiBaseUrl,
+ accessKey: apiAccessKey,
+ secretKey: apiSecretKey,
+ scope,
+ ttlSeconds: Number(appEnv.YOYUZH_DOGECLOUD_STORAGE_TTL_SECONDS || '3600'),
+ });
+
const files = await queryFiles(options.database);
const context = {
endpoint,
+ region,
bucket,
accessKeyId,
- accessKeySecret,
+ secretAccessKey,
+ sessionToken,
};
const archivedObjectsByKey = await buildArchivedObjectMap(context, files);
diff --git a/scripts/oss-deploy-lib.mjs b/scripts/oss-deploy-lib.mjs
index 69475b4..7707d40 100644
--- a/scripts/oss-deploy-lib.mjs
+++ b/scripts/oss-deploy-lib.mjs
@@ -68,29 +68,179 @@ export function getFrontendSpaAliasContentType() {
return 'text/html; charset=utf-8';
}
-export function createAuthorizationHeader({
+function toAmzDateParts(amzDate) {
+ return {
+ amzDate,
+ dateStamp: amzDate.slice(0, 8),
+ };
+}
+
+function sha256Hex(value) {
+ return crypto.createHash('sha256').update(value).digest('hex');
+}
+
+function hmac(key, value, encoding) {
+ const digest = crypto.createHmac('sha256', key).update(value).digest();
+ return encoding ? digest.toString(encoding) : digest;
+}
+
+function hmacSha1Hex(key, value) {
+ return crypto.createHmac('sha1', key).update(value).digest('hex');
+}
+
+function buildSigningKey(secretAccessKey, dateStamp, region, service) {
+ const kDate = hmac(`AWS4${secretAccessKey}`, dateStamp);
+ const kRegion = hmac(kDate, region);
+ const kService = hmac(kRegion, service);
+ return hmac(kService, 'aws4_request');
+}
+
+function encodeQueryComponent(value) {
+ return encodeURIComponent(value).replace(/[!'()*]/g, (character) =>
+ `%${character.charCodeAt(0).toString(16).toUpperCase()}`
+ );
+}
+
+function toCanonicalQueryString(query) {
+ const params = new URLSearchParams(query);
+ return [...params.entries()]
+ .sort(([leftKey, leftValue], [rightKey, rightValue]) =>
+ leftKey === rightKey ? leftValue.localeCompare(rightValue) : leftKey.localeCompare(rightKey)
+ )
+ .map(([key, value]) => `${encodeQueryComponent(key)}=${encodeQueryComponent(value)}`)
+ .join('&');
+}
+
+export function extractDogeCloudScopeBucketName(scope) {
+ const separatorIndex = scope.indexOf(':');
+ return separatorIndex >= 0 ? scope.slice(0, separatorIndex) : scope;
+}
+
+export function createDogeCloudApiAuthorization({apiPath, body, accessKey, secretKey}) {
+ return `TOKEN ${accessKey}:${hmacSha1Hex(secretKey, `${apiPath}\n${body}`)}`;
+}
+
+export async function requestDogeCloudTemporaryS3Session({
+ apiBaseUrl = 'https://api.dogecloud.com',
+ accessKey,
+ secretKey,
+ scope,
+ ttlSeconds = 3600,
+ fetchImpl = fetch,
+}) {
+ const apiPath = '/auth/tmp_token.json';
+ const body = JSON.stringify({
+ channel: 'OSS_FULL',
+ ttl: ttlSeconds,
+ scopes: [scope],
+ });
+ const response = await fetchImpl(`${apiBaseUrl.replace(/\/+$/, '')}${apiPath}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: createDogeCloudApiAuthorization({
+ apiPath,
+ body,
+ accessKey,
+ secretKey,
+ }),
+ },
+ body,
+ });
+
+ if (!response.ok) {
+ throw new Error(`DogeCloud tmp_token request failed: HTTP ${response.status}`);
+ }
+
+ const payload = await response.json();
+ if (payload.code !== 200) {
+ throw new Error(`DogeCloud tmp_token request failed: ${payload.msg || 'unknown error'}`);
+ }
+
+ const bucketName = extractDogeCloudScopeBucketName(scope);
+ const buckets = Array.isArray(payload.data?.Buckets) ? payload.data.Buckets : [];
+ const bucket = buckets.find((entry) => entry.name === bucketName) ?? buckets[0];
+ if (!bucket) {
+ throw new Error(`DogeCloud tmp_token response did not include a bucket for scope: ${bucketName}`);
+ }
+
+ return {
+ accessKeyId: payload.data.Credentials?.accessKeyId || '',
+ secretAccessKey: payload.data.Credentials?.secretAccessKey || '',
+ sessionToken: payload.data.Credentials?.sessionToken || '',
+ bucket: bucket.s3Bucket,
+ endpoint: bucket.s3Endpoint,
+ bucketName: bucket.name,
+ expiresAt: payload.data.ExpiredAt,
+ };
+}
+
+export function createAwsV4Headers({
method,
+ endpoint,
bucket,
objectKey,
- contentType,
- date,
+ query = '',
+ headers: extraHeaders = {},
+ amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, ''),
accessKeyId,
- accessKeySecret,
+ secretAccessKey,
+ sessionToken,
+ region = 'automatic',
}) {
- const stringToSign = [
+ const {dateStamp} = toAmzDateParts(amzDate);
+ const normalizedEndpoint = normalizeEndpoint(endpoint);
+ const host = `${bucket}.${normalizedEndpoint}`;
+ const canonicalUri = `/${encodeObjectKey(objectKey)}`;
+ const canonicalQueryString = toCanonicalQueryString(query);
+ const payloadHash = 'UNSIGNED-PAYLOAD';
+ const service = 's3';
+ const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
+ const signedHeaderEntries = [
+ ['host', host],
+ ['x-amz-content-sha256', payloadHash],
+ ['x-amz-date', amzDate],
+ ];
+
+ for (const [key, value] of Object.entries(extraHeaders)) {
+ signedHeaderEntries.push([key.toLowerCase(), String(value).trim()]);
+ }
+
+ if (sessionToken) {
+ signedHeaderEntries.push(['x-amz-security-token', sessionToken]);
+ }
+
+ signedHeaderEntries.sort(([left], [right]) => left.localeCompare(right));
+ const signedHeaders = signedHeaderEntries.map(([key]) => key).join(';');
+ const canonicalHeadersText = signedHeaderEntries.map(([key, value]) => `${key}:${value}\n`).join('');
+ const canonicalRequest = [
method.toUpperCase(),
- '',
- contentType,
- date,
- `/${bucket}/${objectKey}`,
+ canonicalUri,
+ canonicalQueryString,
+ canonicalHeadersText,
+ signedHeaders,
+ payloadHash,
].join('\n');
+ const stringToSign = [
+ 'AWS4-HMAC-SHA256',
+ amzDate,
+ credentialScope,
+ sha256Hex(canonicalRequest),
+ ].join('\n');
+ const signature = hmac(buildSigningKey(secretAccessKey, dateStamp, region, service), stringToSign, 'hex');
- const signature = crypto
- .createHmac('sha1', accessKeySecret)
- .update(stringToSign)
- .digest('base64');
+ const resultHeaders = {
+ Authorization: `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
+ 'x-amz-content-sha256': payloadHash,
+ 'x-amz-date': amzDate,
+ ...extraHeaders,
+ };
- return `OSS ${accessKeyId}:${signature}`;
+ if (sessionToken) {
+ resultHeaders['x-amz-security-token'] = sessionToken;
+ }
+
+ return resultHeaders;
}
export function encodeObjectKey(objectKey) {
diff --git a/scripts/oss-deploy-lib.test.mjs b/scripts/oss-deploy-lib.test.mjs
index e683bc7..ffb12ea 100644
--- a/scripts/oss-deploy-lib.test.mjs
+++ b/scripts/oss-deploy-lib.test.mjs
@@ -3,12 +3,15 @@ import test from 'node:test';
import {
buildObjectKey,
- createAuthorizationHeader,
+ createDogeCloudApiAuthorization,
+ createAwsV4Headers,
+ extractDogeCloudScopeBucketName,
getFrontendSpaAliasContentType,
getFrontendSpaAliasKeys,
getCacheControl,
getContentType,
normalizeEndpoint,
+ requestDogeCloudTemporaryS3Session,
} from './oss-deploy-lib.mjs';
test('normalizeEndpoint strips scheme and trailing slashes', () => {
@@ -43,16 +46,98 @@ test('frontend spa aliases are uploaded as html entry points', () => {
assert.equal(getFrontendSpaAliasContentType(), 'text/html; charset=utf-8');
});
-test('createAuthorizationHeader is stable for a known request', () => {
- const header = createAuthorizationHeader({
+test('createAwsV4Headers signs uploads with S3-compatible SigV4 headers', () => {
+ const headers = createAwsV4Headers({
method: 'PUT',
+ endpoint: 'https://cos.ap-chengdu.myqcloud.com',
bucket: 'demo-bucket',
objectKey: 'assets/index.js',
contentType: 'text/javascript; charset=utf-8',
- date: 'Tue, 17 Mar 2026 12:00:00 GMT',
+ amzDate: '20260317T120000Z',
accessKeyId: 'test-id',
- accessKeySecret: 'test-secret',
+ secretAccessKey: 'test-secret',
+ region: 'automatic',
});
- assert.equal(header, 'OSS test-id:JgyH7mTiSILGGWsnXJwg4KIBRO4=');
+ assert.equal(headers['x-amz-content-sha256'], 'UNSIGNED-PAYLOAD');
+ assert.equal(headers['x-amz-date'], '20260317T120000Z');
+ assert.ok(headers.Authorization.startsWith('AWS4-HMAC-SHA256 Credential=test-id/20260317/automatic/s3/aws4_request'));
+});
+
+test('extractDogeCloudScopeBucketName keeps only the logical bucket name', () => {
+ assert.equal(extractDogeCloudScopeBucketName('yoyuzh-files:users/*'), 'yoyuzh-files');
+ assert.equal(extractDogeCloudScopeBucketName('yoyuzh-front'), 'yoyuzh-front');
+});
+
+test('createDogeCloudApiAuthorization signs body with HMAC-SHA1 hex', () => {
+ const authorization = createDogeCloudApiAuthorization({
+ apiPath: '/auth/tmp_token.json',
+ body: '{"channel":"OSS_FULL","ttl":1800,"scopes":["yoyuzh-files"]}',
+ accessKey: 'doge-ak',
+ secretKey: 'doge-sk',
+ });
+
+ assert.equal(
+ authorization,
+ 'TOKEN doge-ak:2cf0cf7cf6ddaf673cfe47e55646779d44470929'
+ );
+});
+
+test('requestDogeCloudTemporaryS3Session requests temp credentials and returns matching bucket', async () => {
+ const requests = [];
+ const session = await requestDogeCloudTemporaryS3Session({
+ apiBaseUrl: 'https://api.dogecloud.com',
+ accessKey: 'doge-ak',
+ secretKey: 'doge-sk',
+ scope: 'yoyuzh-front:assets/*',
+ ttlSeconds: 1200,
+ fetchImpl: async (url, options) => {
+ requests.push({url, options});
+ return {
+ ok: true,
+ status: 200,
+ async json() {
+ return {
+ code: 200,
+ msg: 'OK',
+ data: {
+ Credentials: {
+ accessKeyId: 'tmp-ak',
+ secretAccessKey: 'tmp-sk',
+ sessionToken: 'tmp-token',
+ },
+ ExpiredAt: 1777777777,
+ Buckets: [
+ {
+ name: 'yoyuzh-files',
+ s3Bucket: 's-cd-14873-yoyuzh-files-1258813047',
+ s3Endpoint: 'https://cos.ap-chengdu.myqcloud.com',
+ },
+ {
+ name: 'yoyuzh-front',
+ s3Bucket: 's-cd-14873-yoyuzh-front-1258813047',
+ s3Endpoint: 'https://cos.ap-chengdu.myqcloud.com',
+ },
+ ],
+ },
+ };
+ },
+ };
+ },
+ });
+
+ assert.equal(requests[0].url, 'https://api.dogecloud.com/auth/tmp_token.json');
+ assert.equal(requests[0].options.method, 'POST');
+ assert.equal(requests[0].options.headers['Content-Type'], 'application/json');
+ assert.ok(requests[0].options.headers.Authorization.startsWith('TOKEN doge-ak:'));
+ assert.equal(requests[0].options.body, '{"channel":"OSS_FULL","ttl":1200,"scopes":["yoyuzh-front:assets/*"]}');
+ assert.deepEqual(session, {
+ accessKeyId: 'tmp-ak',
+ secretAccessKey: 'tmp-sk',
+ sessionToken: 'tmp-token',
+ bucket: 's-cd-14873-yoyuzh-front-1258813047',
+ endpoint: 'https://cos.ap-chengdu.myqcloud.com',
+ bucketName: 'yoyuzh-front',
+ expiresAt: 1777777777,
+ });
});