588 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
const http = require('http');
const https = require('https');
const crypto = require('crypto');
const Core = require('@alicloud/pop-core');
const TableStore = require('tablestore');
const MAX_BODY_SIZE = 1024;
const PORT = 9000;
const TOKEN_CACHE_DURATION_MS = 5 * 60 * 1000; // 5分钟
const STEAM_AUTH_CACHE_DURATION_MS = 10 * 60 * 1000; // 10分钟
const MAX_STANDARD_UPLOAD_SIZE = 3 * 1024 * 1024; // 3MB
const MAX_BUG_REPORT_UPLOAD_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_MULTILINGUAL_REPORT_UPLOAD_SIZE = 1 * 1024 * 1024; // 1MB
const STS_DURATION_SECONDS = 900; // 15分钟
const UPLOAD_TYPE_CONFIG = {
ossdata: {
cache: true,
extension: 'dat',
maxUploadSize: MAX_STANDARD_UPLOAD_SIZE,
buildPathPrefix: versionSegment => versionSegment
},
collectdata: {
cache: true,
extension: 'dat',
maxUploadSize: MAX_STANDARD_UPLOAD_SIZE,
buildPathPrefix: versionSegment => `collect/${versionSegment}`
},
bugreport: {
cache: false,
extension: 'zip',
maxUploadSize: MAX_BUG_REPORT_UPLOAD_SIZE,
uniqueObjectKey: true,
buildPathPrefix: versionSegment => `bugreport/${versionSegment}`
},
multilingualreport: {
cache: false,
extension: 'zip',
maxUploadSize: MAX_MULTILINGUAL_REPORT_UPLOAD_SIZE,
uniqueObjectKey: true,
buildPathPrefix: versionSegment => `multilingualreport/${versionSegment}`
}
};
const ACTION_STEAM_AUTH = 'steamauth';
/**
* 获取 Tablestore 客户端
*/
function getOtsClient() {
return new TableStore.Client({
accessKeyId: process.env.ACCESS_KEY_ID,
accessKeySecret: process.env.ACCESS_KEY_SECRET,
endpoint: process.env.OTS_ENDPOINT,
instancename: process.env.OTS_INSTANCE
});
}
function normalizeOtsAttributes(row) {
const attrs = {};
if (!row || !row.attributes) return attrs;
for (const attr of row.attributes) {
const value = attr.columnValue;
attrs[attr.columnName] = (value && typeof value.toNumber === 'function')
? value.toNumber()
: value;
}
return attrs;
}
/**
* 从 Tablestore 获取缓存的令牌
* @param {string} steamId
* @param {string|null} version 客户端版本号null 表示老版本未传
* @param {string} type 上传类型:'ossdata' | 'collectdata' | 'bugreport' | 'multilingualreport'
*/
async function getCachedToken(steamId, version, type) {
const otsClient = getOtsClient();
const cacheKey = `${steamId}#${type}`;
const params = {
tableName: 'Players',
primaryKey: [{ PlayerId: cacheKey }],
maxVersions: 1
};
try {
console.log(`[缓存查询] 正在查询 SteamID: ${steamId} 的缓存令牌...`);
const result = await otsClient.getRow(params);
const row = result.row;
if (!row || !row.attributes || row.attributes.length === 0) {
console.log(`[缓存查询] SteamID: ${steamId} 无缓存记录`);
return null;
}
const attrs = normalizeOtsAttributes(row);
// 版本号比对:缓存中存储的版本号(无版本号时为空字符串或不存在)
const cachedVersion = attrs.version || null;
const requestVersion = version || null;
if (cachedVersion !== requestVersion) {
console.log(`[缓存查询] SteamID: ${steamId} 版本号不匹配,缓存版本: ${cachedVersion ?? '(无)'}, 请求版本: ${requestVersion ?? '(无)'},忽略缓存`);
return null;
}
const issuedAt = attrs.issuedAt;
if (!issuedAt || Date.now() - issuedAt > TOKEN_CACHE_DURATION_MS) {
console.log(`[缓存查询] SteamID: ${steamId} 缓存已过期(签发时间: ${new Date(issuedAt).toISOString()}`);
return null;
}
const stsExpireAt = attrs.stsExpireAt;
if (!stsExpireAt || stsExpireAt - Date.now() < 2 * 60 * 1000) {
const remaining = stsExpireAt ? Math.floor((stsExpireAt - Date.now()) / 1000) : 0;
console.log(`[缓存查询] SteamID: ${steamId} STS令牌剩余有效期不足剩余 ${remaining} 秒),需重新签发`);
return null;
}
const expiresIn = Math.floor((stsExpireAt - Date.now()) / 1000);
console.log(`[缓存命中] SteamID: ${steamId} 命中缓存,版本: ${cachedVersion ?? '(无)'},剩余有效期 ${expiresIn}`);
return {
accessKeyId: attrs.accessKeyId,
accessKeySecret: attrs.accessKeySecret,
securityToken: attrs.securityToken,
endpoint: attrs.endpoint,
bucket: attrs.bucket,
objectKey: attrs.objectKey,
policy: attrs.policy,
signature: attrs.signature,
expiresIn: expiresIn
};
} catch (e) {
console.error(`[缓存查询] 读取缓存失败SteamID: ${steamId},错误: ${e.message}`);
return null;
}
}
/**
* 将令牌存入 Tablestore
* @param {string} steamId
* @param {object} tokenData
* @param {string|null} version 客户端版本号null 表示老版本未传
* @param {string} type 上传类型:'ossdata' | 'collectdata' | 'bugreport' | 'multilingualreport'
*/
async function saveTokenToCache(steamId, tokenData, version, type) {
const otsClient = getOtsClient();
const cacheKey = `${steamId}#${type}`;
const now = Date.now();
const stsExpireAt = now + tokenData.expiresIn * 1000;
const attributeColumns = [
{ accessKeyId: tokenData.accessKeyId },
{ accessKeySecret: tokenData.accessKeySecret },
{ securityToken: tokenData.securityToken },
{ endpoint: tokenData.endpoint },
{ bucket: tokenData.bucket },
{ objectKey: tokenData.objectKey },
{ policy: tokenData.policy },
{ signature: tokenData.signature },
{ issuedAt: TableStore.Long.fromNumber(now) },
{ stsExpireAt: TableStore.Long.fromNumber(stsExpireAt) },
// version 为 null 时存空字符串,与查询时保持一致
{ version: version !== null && version !== undefined ? version : '' }
];
const params = {
tableName: 'Players',
condition: new TableStore.Condition(TableStore.RowExistenceExpectation.IGNORE, null),
primaryKey: [{ PlayerId: cacheKey }],
attributeColumns,
returnContent: { returnType: TableStore.ReturnType.NONE }
};
try {
console.log(`[缓存写入] 正在写入 SteamID: ${steamId} [${type}] 的令牌缓存,版本: ${version ?? '(无)'}...`);
await otsClient.putRow(params);
console.log(`[缓存写入] SteamID: ${steamId} [${type}] 缓存写入成功,过期时间: ${new Date(stsExpireAt).toISOString()}`);
} catch (e) {
console.error(`[缓存写入] SteamID: ${steamId} [${type}] 缓存写入失败,错误: ${e.message}`);
}
}
/**
* 查询 Steam 身份缓存。该缓存只证明“此 SteamID 近期通过 Steam Web API 校验”,
* 不绑定上传类型,也不直接授权 OSS 写入。
*/
async function getCachedSteamAuth(steamId) {
const otsClient = getOtsClient();
const cacheKey = `${steamId}#steamauth`;
const params = {
tableName: 'Players',
primaryKey: [{ PlayerId: cacheKey }],
maxVersions: 1
};
try {
const result = await otsClient.getRow(params);
const row = result.row;
if (!row || !row.attributes || row.attributes.length === 0) {
console.log(`[Steam缓存] SteamID: ${steamId} 无身份缓存`);
return null;
}
const attrs = normalizeOtsAttributes(row);
const authExpireAt = attrs.authExpireAt;
if (!authExpireAt || authExpireAt <= Date.now()) {
console.log(`[Steam缓存] SteamID: ${steamId} 身份缓存已过期`);
return null;
}
const expiresIn = Math.floor((authExpireAt - Date.now()) / 1000);
console.log(`[Steam缓存] SteamID: ${steamId} 命中身份缓存,剩余 ${expiresIn}`);
return {
steamId,
version: attrs.version || null,
verifiedAt: attrs.verifiedAt || null,
expiresIn
};
} catch (e) {
console.error(`[Steam缓存] 读取身份缓存失败SteamID: ${steamId},错误: ${e.message}`);
return null;
}
}
/**
* 写入 Steam 身份缓存,供后续上传跳过实时 Steam Web API 校验。
*/
async function saveSteamAuthCache(steamId, version) {
const otsClient = getOtsClient();
const cacheKey = `${steamId}#steamauth`;
const now = Date.now();
const authExpireAt = now + STEAM_AUTH_CACHE_DURATION_MS;
const params = {
tableName: 'Players',
condition: new TableStore.Condition(TableStore.RowExistenceExpectation.IGNORE, null),
primaryKey: [{ PlayerId: cacheKey }],
attributeColumns: [
{ verifiedAt: TableStore.Long.fromNumber(now) },
{ authExpireAt: TableStore.Long.fromNumber(authExpireAt) },
{ version: version !== null && version !== undefined ? version : '' }
],
returnContent: { returnType: TableStore.ReturnType.NONE }
};
await otsClient.putRow(params);
const expiresIn = Math.floor(STEAM_AUTH_CACHE_DURATION_MS / 1000);
console.log(`[Steam缓存] SteamID: ${steamId} 身份缓存写入成功,过期时间: ${new Date(authExpireAt).toISOString()}`);
return { expiresIn, authExpireAt };
}
/**
* 生成 Post Policy 和签名
*/
function generatePostPolicy(accessKeySecret, objectKey, bucket, expiration, maxUploadSize) {
const policy = {
expiration: expiration,
conditions: [
{ bucket: bucket },
['eq', '$key', objectKey],
['content-length-range', 1, maxUploadSize]
]
};
const policyBase64 = Buffer.from(JSON.stringify(policy)).toString('base64');
const signature = crypto
.createHmac('sha1', accessKeySecret)
.update(policyBase64)
.digest('base64');
console.log(`[PostPolicy] 生成策略objectKey: ${objectKey},最大文件: ${maxUploadSize} 字节,过期: ${expiration}`);
return { policy: policyBase64, signature };
}
function normalizeUploadType(type) {
return Object.prototype.hasOwnProperty.call(UPLOAD_TYPE_CONFIG, type) ? type : 'ossdata';
}
function normalizeAction(action) {
return action === ACTION_STEAM_AUTH ? ACTION_STEAM_AUTH : 'upload';
}
async function ensureSteamVerified(steamId, authTicket, version, requestId, reason) {
const cachedAuth = await getCachedSteamAuth(steamId);
if (cachedAuth) {
console.log(`[Steam验证] RequestId: ${requestId}SteamID: ${steamId} ${reason} 命中身份缓存,跳过实时校验`);
return { success: true, cached: true, expiresIn: cachedAuth.expiresIn };
}
if (!authTicket) {
return { success: false, statusCode: 400, error: 'authTicket is required' };
}
console.log(`[Steam验证] RequestId: ${requestId}SteamID: ${steamId} ${reason} 未命中身份缓存,开始实时校验...`);
const verification = await verifySteamTicket(authTicket, steamId);
if (!verification.success) {
return { success: false, statusCode: 403, error: `Steam verification failed: ${verification.error}` };
}
try {
const cache = await saveSteamAuthCache(steamId, version);
return { success: true, cached: false, expiresIn: cache.expiresIn };
} catch (e) {
console.error(`[Steam缓存] SteamID: ${steamId} 身份缓存写入失败,错误: ${e.message}`);
return { success: true, cached: false, expiresIn: 0 };
}
}
/**
* 调用 Steam Web API 验证 Auth Ticket带重试
*/
async function verifySteamTicket(ticket, expectedSteamId) {
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`[Steam验证] 第 ${attempt}/${maxRetries} 次尝试验证 SteamID: ${expectedSteamId}`);
const result = await verifySteamTicketOnce(ticket, expectedSteamId);
return result;
} catch (e) {
console.error(`[Steam验证] 第 ${attempt} 次请求异常: ${e.message}`);
if (attempt === maxRetries) {
return { success: false, error: `Steam API 请求失败(重试 ${maxRetries} 次): ${e.message}` };
}
await new Promise(r => setTimeout(r, 1000));
}
}
}
function verifySteamTicketOnce(ticket, expectedSteamId) {
return new Promise((resolve, reject) => {
const steamApiKey = process.env.STEAM_API_KEY;
const appId = process.env.STEAM_APP_ID;
console.log(`[Steam验证] 开始验证 SteamID: ${expectedSteamId}AppID: ${appId}Ticket长度: ${ticket.length}`);
const url = `https://api.steampowered.com/ISteamUserAuth/AuthenticateUserTicket/v1/?key=${steamApiKey}&appid=${appId}&ticket=${ticket}`;
const req = https.get(url, (res) => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
try {
const json = JSON.parse(data);
const params = json?.response?.params;
if (!params) {
const error = json?.response?.error?.errordesc || 'Steam 验证失败';
console.error(`[Steam验证] SteamID: ${expectedSteamId} 验证失败: ${error}`);
resolve({ success: false, error });
return;
}
if (params.result !== 'OK') {
console.error(`[Steam验证] SteamID: ${expectedSteamId} 验证结果异常: ${params.result}`);
resolve({ success: false, error: `Steam auth result: ${params.result}` });
return;
}
if (params.steamid !== expectedSteamId) {
console.error(`[Steam验证] SteamID 不匹配,期望: ${expectedSteamId},实际: ${params.steamid}`);
resolve({ success: false, error: 'SteamID mismatch' });
return;
}
console.log(`[Steam验证] SteamID: ${expectedSteamId} 验证通过 ✅`);
resolve({ success: true });
} catch (e) {
console.error(`[Steam验证] 解析响应失败: ${e.message},原始数据: ${data.substring(0, 200)}`);
resolve({ success: false, error: '解析 Steam API 响应失败' });
}
});
res.on('error', (e) => {
console.error(`[Steam验证] 响应流错误: ${e.message}`);
reject(e);
});
});
req.setTimeout(15000, () => {
console.error(`[Steam验证] 请求超时15秒主动销毁连接`);
req.destroy(new Error('Steam API 请求超时15秒'));
});
req.on('error', (e) => {
console.error(`[Steam验证] HTTPS 请求失败: ${e.message}`);
reject(e);
});
});
}
async function handleRequest(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end('');
return;
}
if (req.method !== 'POST') {
console.warn(`[请求拒绝] 不支持的请求方法: ${req.method}`);
res.writeHead(405, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
return;
}
const requiredEnv = ['ACCESS_KEY_ID', 'ACCESS_KEY_SECRET', 'ROLE_ARN', 'BUCKET_NAME', 'STEAM_API_KEY', 'OTS_ENDPOINT', 'OTS_INSTANCE', 'STEAM_APP_ID'];
const missing = requiredEnv.filter(k => !process.env[k]);
if (missing.length) {
console.error(`[环境变量] 缺少必要的环境变量: ${missing.join(', ')}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `Missing environment variables: ${missing.join(', ')}` }));
return;
}
let body = '';
for await (const chunk of req) {
body += chunk;
if (Buffer.byteLength(body, 'utf8') > MAX_BODY_SIZE) {
console.warn(`[请求拒绝] 请求体超过最大限制 ${MAX_BODY_SIZE} 字节`);
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Payload Too Large' }));
return;
}
}
try {
const requestData = JSON.parse(body || '{}');
const { steamId, authTicket } = requestData;
// version 为可选字段,老版本不传则为 null
const version = (typeof requestData.version === 'string' && requestData.version.trim() !== '')
? requestData.version.trim()
: null;
const action = normalizeAction(requestData.action);
if (!steamId) {
console.warn('[参数校验] 缺少 steamId 参数');
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'steamId is required' }));
return;
}
const requestId = req.headers['x-fc-request-id'] || '-';
console.log(`[请求开始] RequestId: ${requestId}SteamID: ${steamId}Action: ${action}AuthTicket长度: ${authTicket ? authTicket.length : 0},版本: ${version ?? '(无)'}`);
if (action === ACTION_STEAM_AUTH) {
const verification = await ensureSteamVerified(steamId, authTicket, version, requestId, '[预校验]');
if (!verification.success) {
console.error(`[请求失败] RequestId: ${requestId}SteamID: ${steamId} Steam预校验失败: ${verification.error}`);
res.writeHead(verification.statusCode || 403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: verification.error }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
cached: verification.cached,
steamId,
version: version ?? '',
expiresIn: verification.expiresIn
}));
return;
}
// type 为可选字段,默认 'ossdata',兼容老客户端
const type = normalizeUploadType(requestData.type);
const typeConfig = UPLOAD_TYPE_CONFIG[type];
console.log(`[上传请求] RequestId: ${requestId}SteamID: ${steamId},版本: ${version ?? '(无)'},类型: ${type}`);
// 1. 先检查 Tablestore 缓存(版本号必须一致才命中)
if (typeConfig.cache) {
const cachedToken = await getCachedToken(steamId, version, type);
if (cachedToken) {
console.log(`[请求完成] RequestId: ${requestId}SteamID: ${steamId} [${type}] — 返回缓存令牌,剩余有效期 ${cachedToken.expiresIn}`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(cachedToken));
return;
}
} else {
console.log(`[缓存跳过] SteamID: ${steamId} [${type}] 需要每次生成新的上传对象,跳过缓存`);
}
// 2. Steam Ticket 验证(优先复用预校验身份缓存)
const verification = await ensureSteamVerified(steamId, authTicket, version, requestId, `[${type}]`);
if (!verification.success) {
console.error(`[请求失败] RequestId: ${requestId}SteamID: ${steamId} Steam验证失败: ${verification.error}`);
res.writeHead(verification.statusCode || 403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: verification.error }));
return;
}
// 3. 申请 STS 令牌
console.log(`[STS签发] SteamID: ${steamId} [${type}] Steam身份有效正在申请 STS 令牌...`);
const client = new Core({
accessKeyId: process.env.ACCESS_KEY_ID,
accessKeySecret: process.env.ACCESS_KEY_SECRET,
endpoint: 'https://sts.cn-shanghai.aliyuncs.com',
apiVersion: '2015-04-01'
});
// 根据 type 和版本号构建文件路径
// ossdata: {version}/{steamId}/{timestamp}.dat 或 common/{steamId}/{timestamp}.dat
// collectdata: collect/{version}/{steamId}/{timestamp}.dat 或 collect/common/{steamId}/{timestamp}.dat
// bugreport: bugreport/{version}/{steamId}/{timestamp}-{random}.zip 或 bugreport/common/{steamId}/{timestamp}-{random}.zip
// multilingualreport: multilingualreport/{version}/{steamId}/{timestamp}-{random}.zip 或 multilingualreport/common/{steamId}/{timestamp}-{random}.zip
const versionSegment = version !== null ? version : 'common';
const pathPrefix = typeConfig.buildPathPrefix(versionSegment);
const uniqueSuffix = typeConfig.uniqueObjectKey
? `-${crypto.randomUUID().replace(/-/g, '').substring(0, 12)}`
: '';
const objectKey = `${pathPrefix}/${steamId}/${Date.now()}${uniqueSuffix}.${typeConfig.extension}`;
console.log(`[路径构建] SteamID: ${steamId},类型: ${type},版本: ${version ?? '(无)'}ObjectKey: ${objectKey}`);
const stsParams = {
RoleArn: process.env.ROLE_ARN,
RoleSessionName: `p-${steamId}`.substring(0, 32),
DurationSeconds: STS_DURATION_SECONDS,
Policy: JSON.stringify({
Version: '1',
Statement: [{
Effect: 'Allow',
Action: ['oss:PutObject'],
Resource: [`acs:oss:*:*:${process.env.BUCKET_NAME}/${objectKey}`]
}]
})
};
const result = await client.request('AssumeRole', stsParams);
console.log(`[STS签发] SteamID: ${steamId} STS令牌签发成功ObjectKey: ${objectKey}`);
// 4. 生成 Post Policy 和签名
const expiration = new Date(Date.now() + STS_DURATION_SECONDS * 1000).toISOString();
const { policy, signature } = generatePostPolicy(
result.Credentials.AccessKeySecret,
objectKey,
process.env.BUCKET_NAME,
expiration,
typeConfig.maxUploadSize
);
const tokenData = {
accessKeyId: result.Credentials.AccessKeyId,
accessKeySecret: result.Credentials.AccessKeySecret,
securityToken: result.Credentials.SecurityToken,
endpoint: 'oss-cn-shanghai.aliyuncs.com',
bucket: process.env.BUCKET_NAME,
objectKey: objectKey,
policy: policy,
signature: signature,
expiresIn: STS_DURATION_SECONDS
};
// 5. 存入 Tablestore 缓存(携带版本号和类型)
if (typeConfig.cache) {
await saveTokenToCache(steamId, tokenData, version, type);
}
console.log(`[请求完成] RequestId: ${requestId}SteamID: ${steamId} [${type}] 全流程完成 ✅(验证 → 签发 → Policy${typeConfig.cache ? ' → 缓存' : ''}`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(tokenData));
} catch (error) {
const msg = error && error.message ? error.message : 'Internal Server Error';
console.error(`[请求异常] 未捕获的错误: ${msg}`);
console.error(`[请求异常] 错误堆栈: ${error?.stack || '无堆栈信息'}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: msg }));
}
}
const server = http.createServer(handleRequest);
server.listen(PORT, '0.0.0.0', () => {
console.log(`[服务启动] 服务器已启动,监听端口: ${PORT},标准最大上传: ${MAX_STANDARD_UPLOAD_SIZE} 字节Bug汇报最大上传: ${MAX_BUG_REPORT_UPLOAD_SIZE} 字节,多语言汇报最大上传: ${MAX_MULTILINGUAL_REPORT_UPLOAD_SIZE} 字节`);
})