Compare commits

..

2 Commits

Author SHA1 Message Date
JamesFlare1212
a8f468a497 feat: 使用 Playwright 实现自动化 cookie 获取和验证
主要变更:
- 新增 Playwright 登录认证服务 (services/playwright-auth.ts)
- 重构 get-activity.ts 使用 Playwright 替代 Axios 登录
- 实现自动 cookie 过期检测和重试机制
- 优化 Docker 配置支持 Playwright 浏览器运行
- 添加启动脚本自动验证和刷新 cookies
- 完善错误处理:区分 4xx(认证失败) 和 5xx(服务器错误)

技术细节:
- 删除旧版 login_template.txt 和 nkcs-engage.cookie.txt
- 添加 startup.sh 启动时自动验证 cookies
- 改进 cookie 验证逻辑,添加指数退避重试
- Dockerfile 安装 Playwright 系统依赖
- docker-compose.yaml 添加 volumes 和 health checks

测试:
- 添加 auth.spec.ts 自动化测试
- 添加 get-cookies.ts 和 test-cookies-validity.ts 工具脚本
- 验证 401/500/000 等错误场景处理正确
2026-04-06 16:05:38 -04:00
JamesFlare1212
b18b8a85e0 feat new s3 public url option 2026-03-15 19:40:59 -04:00
16 changed files with 694 additions and 184 deletions

View File

@@ -1,15 +1,53 @@
FROM oven/bun:latest
ENV NODE_ENV=production
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
ENV DEBIAN_FRONTEND=noninteractive
WORKDIR /usr/src/app
# Install Playwright system dependencies
RUN apt-get update && apt-get install -y \
libnss3 \
libnspr4 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libasound2 \
libpango-1.0-0 \
libcairo2 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Copy dependency files
COPY package.json bun.lock ./
# Install dependencies (including Playwright)
RUN bun install --production
# Install Playwright browsers
RUN bunx playwright install chromium --with-deps || true
# Copy application code
COPY . .
# Make startup script executable
RUN chmod +x startup.sh
# Create non-root user for security
RUN adduser --disabled-password --gecos '' appuser && \
chown -R appuser:appuser /usr/src/app
USER appuser
EXPOSE 3000
CMD ["bun", "start"]
# Use startup script
CMD ["/bin/sh", "startup.sh"]

View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "dsas-cca-backend-bun",
@@ -15,10 +16,12 @@
"uuid": "^11.1.0",
},
"devDependencies": {
"@playwright/test": "^1.49.0",
"@types/bun": "latest",
"typescript-language-server": "^5.1.3",
},
"peerDependencies": {
"typescript": "^5",
"typescript": "^5.9.3",
},
},
},
@@ -65,6 +68,8 @@
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.1", "", { "os": "win32", "cpu": "x64" }, "sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw=="],
"@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="],
"@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="],
"@types/node": ["@types/node@22.15.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw=="],
@@ -147,6 +152,8 @@
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
@@ -203,6 +210,10 @@
"path-to-regexp": ["path-to-regexp@8.2.0", "", {}, "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ=="],
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
@@ -247,7 +258,9 @@
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript-language-server": ["typescript-language-server@5.1.3", "", { "bin": { "typescript-language-server": "lib/cli.mjs" } }, "sha512-r+pAcYtWdN8tKlYZPwiiHNA2QPjXnI02NrW5Sf2cVM3TRtuQ3V9EKKwOxqwaQ0krsaEXk/CbN90I5erBuf84Vg=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],

View File

@@ -5,21 +5,29 @@ services:
dockerfile: Dockerfile
container_name: dsas-cca-backend
ports:
- "${PORT}:${PORT}"
- "${PORT:-3000}:${PORT:-3000}"
env_file:
- .env
environment:
- NODE_ENV=production
- PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
restart: unless-stopped
depends_on:
- redis
redis:
condition: service_healthy
volumes:
- ./services/cookies.json:/usr/src/app/services/cookies.json
networks:
- cca_network
mem_limit: 1g
cpus: 1.0
redis:
image: "redis:8.0-alpine"
container_name: dsas-cca-redis
command: redis-server --requirepass "dsas-cca"
volumes:
- ./redis_data:/data
- redis_data:/data
restart: unless-stopped
networks:
- cca_network
@@ -28,6 +36,10 @@ services:
interval: 10s
timeout: 5s
retries: 5
mem_limit: 256m
volumes:
redis_data:
networks:
cca_network:

View File

@@ -1,13 +1,18 @@
// engage-api/get-activity.ts
import axios from 'axios';
import { readFile,writeFile,unlink } from 'fs/promises';
import { resolve } from 'path';
import { logger } from '../utils/logger';
import {
loginWithPlaywright,
loadCachedCookies,
saveCookiesToCache,
clearCookieCache,
getCachedCookieString
} from '../services/playwright-auth';
// Define interfaces for our data structures
interface ActivityResponse {
d: string;
isError ? : boolean;
isError?: boolean;
[key: string]: any;
}
@@ -15,71 +20,19 @@ interface ActivityResponse {
class AuthenticationError extends Error {
status: number;
constructor(message: string = "Authentication failed, cookie may be invalid.", status ? : number) {
constructor(message: string = "Authentication failed, cookie may be invalid.", status?: number) {
super(message);
this.name = "AuthenticationError";
this.status = status || 0;
}
}
// In Bun, we can use import.meta.dir instead of the Node.js __dirname approach
const COOKIE_FILE_PATH = resolve(import.meta.dir, 'nkcs-engage.cookie.txt');
let _inMemoryCookie: string | null = null;
// Cookie Cache Helper Functions
async function loadCachedCookie(): Promise < string | null > {
if (_inMemoryCookie) {
logger.debug("Using in-memory cached cookie.");
return _inMemoryCookie;
}
try {
const cookieFromFile = await readFile(COOKIE_FILE_PATH, 'utf8');
if (cookieFromFile) {
_inMemoryCookie = cookieFromFile;
logger.debug("Loaded cookie from file cache.");
return _inMemoryCookie;
}
} catch (err: any) {
if (err.code === 'ENOENT') {
logger.debug("Cookie cache file not found. No cached cookie loaded.");
} else {
logger.warn("Error loading cookie from file:", err.message);
}
}
return null;
}
async function saveCookieToCache(cookieString: string): Promise < void > {
if (!cookieString) {
logger.warn("Attempted to save an empty or null cookie. Aborting save.");
return;
}
_inMemoryCookie = cookieString;
try {
await writeFile(COOKIE_FILE_PATH, cookieString, 'utf8');
logger.debug("Cookie saved to file cache.");
} catch (err: any) {
logger.error("Error saving cookie to file:", err.message);
}
}
async function clearCookieCache(): Promise < void > {
_inMemoryCookie = null;
try {
await unlink(COOKIE_FILE_PATH);
logger.debug("Cookie cache file deleted.");
} catch (err: any) {
if (err.code !== 'ENOENT') {
logger.error("Error deleting cookie file:", err.message);
} else {
logger.debug("Cookie cache file did not exist, no need to delete.");
}
}
}
async function testCookieValidity(cookieString: string): Promise < boolean > {
/**
* Test cookie validity by calling API
*/
async function testCookieValidityWithApi(cookieString: string): Promise<boolean> {
if (!cookieString) return false;
logger.debug("Testing cookie validity...");
logger.debug('Testing cookie validity via API...');
const MAX_RETRIES = 3;
let attempt = 0;
@@ -98,123 +51,69 @@ async function testCookieValidity(cookieString: string): Promise < boolean > {
};
logger.debug(`Attempt ${attempt}/${MAX_RETRIES}`);
await axios.post(url, payload, {
const response = await axios.post(url, payload, {
headers,
timeout: 20000
});
logger.debug("Cookie test successful (API responded 2xx). Cookie is valid.");
// Check for 4xx errors (auth failures)
if (response.status >= 400 && response.status < 500) {
logger.warn(`Cookie test returned ${response.status}, likely invalid`);
return false;
}
logger.debug('Cookie test successful (API responded 2xx). Cookie is valid.');
return true;
} catch (error: any) {
logger.warn(`Cookie validity test failed (attempt ${attempt}/${MAX_RETRIES}).`);
if (error.response) {
logger.warn(`Cookie test API response status: ${error.response.status}.`);
// 4xx = auth failure (immediate fail)
if (error.response.status >= 400 && error.response.status < 500) {
logger.warn(`Cookie test API response status: ${error.response.status} (auth error)`);
return false;
}
// 5xx = server error (retry with delay)
logger.warn(`Cookie test API response status: ${error.response.status} (server error, retrying...)`);
} else {
logger.warn(`Network/other error: ${error.message}`);
// No response (000 status, network error, timeout)
logger.warn(`Network/timeout error: ${error.message} (retrying...)`);
}
if (attempt >= MAX_RETRIES) {
logger.warn("Max retries reached. Cookie is likely invalid or expired.");
return false;
if (attempt < MAX_RETRIES) {
await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)));
}
}
}
logger.warn('Max retries reached. Cookie is likely invalid or expired.');
return false;
}
// Core API Interaction Functions
async function getSessionId(): Promise < string | null > {
const url = 'https://engage.nkcswx.cn/Login.aspx';
try {
const response = await axios.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Bun DSAS-CCA get-activity Module)'
}
});
const setCookieHeader = response.headers['set-cookie'];
if (setCookieHeader && setCookieHeader.length > 0) {
const sessionIdCookie = setCookieHeader.find(cookie => cookie.trim().startsWith('ASP.NET_SessionId='));
if (sessionIdCookie) {
logger.debug('ASP.NET_SessionId created');
return sessionIdCookie.split(';')[0] || null; // Ensure a fallback to `null` if splitting fails
}
return null; // Explicitly return `null` if no cookie is found
}
logger.error("No ASP.NET_SessionId cookie found in Set-Cookie header.");
return null;
} catch (error: any) {
logger.error(`Error in getSessionId: ${error.response ? `${error.response.status} - ${error.response.statusText}` : error.message}`);
throw error;
/**
* Get complete cookies using Playwright
*/
async function getCompleteCookies(userName: string, userPwd: string): Promise<string> {
logger.info('Attempting to get complete cookie string using Playwright login...');
const cookies = await loginWithPlaywright(userName, userPwd);
if (!cookies || cookies.length === 0) {
throw new Error("Login failed: Could not obtain cookies.");
}
const cookieString = cookies.map(c => `${c.name}=${c.value}`).join('; ');
return cookieString;
}
async function getMSAUTH(sessionId: string, userName: string, userPwd: string, templateFilePath: string): Promise < string | null > {
const url = 'https://engage.nkcswx.cn/Login.aspx';
try {
let templateData = await readFile(templateFilePath, 'utf8');
const postData = templateData
.replace('{{USERNAME}}', userName)
.replace('{{PASSWORD}}', userPwd);
const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Cookie': sessionId,
'User-Agent': 'Mozilla/5.0 (Bun DSAS-CCA get-activity Module)',
'Referer': 'https://engage.nkcswx.cn/Login.aspx'
};
logger.debug('Getting .ASPXFORMSAUTH');
const response = await axios.post(url, postData, {
headers,
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 400
});
const setCookieHeader = response.headers['set-cookie'];
let formsAuthCookieValue = null;
if (setCookieHeader && setCookieHeader.length > 0) {
const aspxAuthCookies = setCookieHeader.filter(cookie => cookie.trim().startsWith('.ASPXFORMSAUTH='));
if (aspxAuthCookies.length > 0) {
for (let i = aspxAuthCookies.length - 1; i >= 0; i--) {
const cookieCandidateParts = aspxAuthCookies[i].split(';');
if (cookieCandidateParts.length > 0 && cookieCandidateParts[0] !== undefined) { // Explicit check
const firstPart = cookieCandidateParts[0].trim();
if (firstPart.length > '.ASPXFORMSAUTH='.length && firstPart.substring('.ASPXFORMSAUTH='.length).length > 0) {
formsAuthCookieValue = firstPart;
break;
}
}
}
}
}
if (formsAuthCookieValue) {
logger.debug('.ASPXFORMSAUTH cookie obtained.');
return formsAuthCookieValue;
} else {
logger.error("No valid .ASPXFORMSAUTH cookie found. Headers:", setCookieHeader || "none");
return null;
}
} catch (error: any) {
if (error.code === 'ENOENT') logger.error(`Error: Template file '${templateFilePath}' not found.`);
else logger.error(`Error in getMSAUTH: ${error.message}`);
throw error;
}
}
async function getCompleteCookies(userName: string, userPwd: string, templateFilePath: string): Promise < string > {
logger.debug('Attempting to get complete cookie string (login process).');
const sessionId = await getSessionId();
if (!sessionId) throw new Error("Login failed: Could not obtain ASP.NET_SessionId.");
const msAuth = await getMSAUTH(sessionId, userName, userPwd, templateFilePath);
if (!msAuth) throw new Error("Login failed: Could not obtain .ASPXFORMSAUTH cookie.");
return `${sessionId}; ${msAuth}`;
}
/**
* Get activity details from API
*/
async function getActivityDetailsRaw(
activityId: string,
cookies: string,
maxRetries: number = 3,
timeoutMilliseconds: number = 20000
): Promise < string | null > {
): Promise<string | null> {
const url = 'https://engage.nkcswx.cn/Services/ActivitiesService.asmx/GetActivityDetails';
const headers = {
'Content-Type': 'application/json; charset=UTF-8',
@@ -270,7 +169,6 @@ async function getActivityDetailsRaw(
* @param activityId - The ID of the activity to fetch.
* @param userName - URL-encoded username.
* @param userPwd - URL-encoded password.
* @param templateFileName - Name of the login template file.
* @param forceLogin - If true, bypasses cached cookie and forces a new login.
* @returns The parsed JSON object of activity details, or null on failure.
*/
@@ -278,10 +176,9 @@ export async function fetchActivityData(
activityId: string,
userName: string,
userPwd: string,
templateFileName: string = "login_template.txt",
forceLogin: boolean = false
): Promise < any | null > {
let currentCookie = forceLogin ? null : await loadCachedCookie();
): Promise<any | null> {
let currentCookie = forceLogin ? null : await getCachedCookieString();
if (forceLogin && currentCookie) {
await clearCookieCache();
@@ -289,21 +186,25 @@ export async function fetchActivityData(
}
if (currentCookie) {
const isValid = await testCookieValidity(currentCookie);
const isValid = await testCookieValidityWithApi(currentCookie);
if (!isValid) {
logger.info("Cached cookie test failed or cookie expired. Clearing cache.");
logger.info('Cached cookie test failed or cookie expired. Clearing cache.');
await clearCookieCache();
currentCookie = null;
} else {
logger.info("Using valid cached cookie.");
logger.info('Using valid cached cookie.');
}
}
if (!currentCookie) {
logger.info(forceLogin ? "Forcing new login." : "No valid cached cookie found or cache bypassed. Attempting login...");
logger.info(forceLogin ? 'Forcing new login.' : 'No valid cached cookie found or cache bypassed. Attempting login...');
try {
currentCookie = await getCompleteCookies(userName, userPwd, resolve(import.meta.dir, templateFileName));
await saveCookieToCache(currentCookie);
currentCookie = await getCompleteCookies(userName, userPwd);
const cookies = await loadCachedCookies();
if (cookies) {
await saveCookiesToCache(cookies);
}
} catch (loginError) {
logger.error(`Login process failed: ${(loginError as Error).message}`);
return null;
@@ -311,7 +212,7 @@ export async function fetchActivityData(
}
if (!currentCookie) {
logger.error("Critical: No cookie available after login attempt. Cannot fetch activity data.");
logger.error('Critical: No cookie available after login attempt. Cannot fetch activity data.');
return null;
}
@@ -329,11 +230,15 @@ export async function fetchActivityData(
await clearCookieCache();
try {
logger.info("Attempting re-login due to authentication failure...");
currentCookie = await getCompleteCookies(userName, userPwd, resolve(import.meta.dir, templateFileName));
await saveCookieToCache(currentCookie);
logger.info('Attempting re-login due to authentication failure...');
currentCookie = await getCompleteCookies(userName, userPwd);
logger.info("Re-login successful. Retrying request for activity details once...");
const cookies = await loadCachedCookies();
if (cookies) {
await saveCookiesToCache(cookies);
}
logger.info('Re-login successful. Retrying request for activity details once...');
const rawActivityDetailsStringRetry = await getActivityDetailsRaw(activityId, currentCookie);
if (rawActivityDetailsStringRetry) {
const parsedOuterRetry = JSON.parse(rawActivityDetailsStringRetry);
@@ -351,6 +256,3 @@ export async function fetchActivityData(
}
}
}
// Optionally
//export { clearCookieCache,testCookieValidity };

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,7 @@ PORT=3000
FIXED_STAFF_ACTIVITY_ID=7095
ALLOWED_ORIGINS=*
S3_ENDPOINT=
S3_PUBLIC_URL=
S3_BUCKET_NAME=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=

View File

@@ -3,13 +3,20 @@
"private": true,
"scripts": {
"start": "bun run index.ts",
"dev": "bun run --watch index.ts"
"dev": "bun run --watch index.ts",
"playwright:install": "bunx playwright install",
"cookie:get": "bun run test/get-cookies.ts",
"test": "bun test",
"test:auth": "bun test test/auth.spec.ts",
"test:ui": "bunx playwright test --ui"
},
"devDependencies": {
"@types/bun": "latest"
"@playwright/test": "^1.49.0",
"@types/bun": "latest",
"typescript-language-server": "^5.1.3"
},
"peerDependencies": {
"typescript": "^5"
"typescript": "^5.9.3"
},
"dependencies": {
"axios": "^1.9.0",

File diff suppressed because one or more lines are too long

24
playwright.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import { defineConfig, type PlaywrightTestConfig } from '@playwright/test';
export default defineConfig({
testDir: './test',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
use: {
baseURL: 'https://engage.nkcswx.cn',
trace: 'on-first-retry',
headless: true,
},
timeout: 60000,
expect: {
timeout: 5000,
},
webServer: {
command: 'echo "No web server needed"',
port: 0,
reuseExistingServer: true,
},
});

32
services/cookies.json Normal file
View File

@@ -0,0 +1,32 @@
[
{
"name": "ASP.NET_SessionId",
"value": "fjurweoenv1wdcvhcyvhreqh",
"domain": "engage.nkcswx.cn",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "usernameCookie",
"value": "1hDdyhfXMJP9S2CpwOgJZDKOXrEEXLB%23EXOogV55NRskzh6XKDU2rym1YrGfnoklj",
"domain": "engage.nkcswx.cn",
"path": "/",
"expires": 1778095681.649044,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
},
{
"name": ".ASPXFORMSAUTH",
"value": "8E4B9D03FE08C5A2C6EB323B35110D6290CCF3408B68940F9783D1F9D37FA326A38457C956652C8A55D68218AA681485AB8C2533E4E7C85B2BF3413847009C18918281566DF940EA26761F8C0424B93AE79F519DDD0585DF19907E1F9231F5C020039960F5BFC53B7D429B1F3F83B5655F83D796",
"domain": "engage.nkcswx.cn",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
}
]

188
services/playwright-auth.ts Normal file
View File

@@ -0,0 +1,188 @@
import { chromium, type BrowserContext, type Cookie } from 'playwright';
import { logger } from '../utils/logger';
import * as fs from 'node:fs';
import { resolve } from 'node:path';
const LOGIN_URL = 'https://engage.nkcswx.cn/Login.aspx';
const COOKIE_FILE_PATH = resolve(import.meta.dir, 'cookies.json');
let _inMemoryCookies: Cookie[] | null = null;
/**
* Login using Playwright and extract cookies
*/
export async function loginWithPlaywright(username: string, password: string): Promise<Cookie[]> {
logger.info('Starting Playwright login process...');
const browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
try {
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
});
const page = await context.newPage();
logger.info(`Navigating to login page: ${LOGIN_URL}`);
await page.goto(LOGIN_URL, {
waitUntil: 'networkidle',
timeout: 30000
});
logger.info('Login page loaded. Filling form...');
const usernameField = page.locator('input[name="ctl00$PageContent$loginControl$txtUN"]');
await usernameField.fill(decodeURIComponent(username));
const passwordField = page.locator('input[name="ctl00$PageContent$loginControl$txtPwd"]');
await passwordField.fill(decodeURIComponent(password));
const rememberMe = page.locator('input[name="ctl00$PageContent$loginControl$cbRememberMe"]');
await rememberMe.check().catch(() => {
logger.debug('Could not check remember me checkbox (optional)');
});
const loginButton = page.locator('input[name="ctl00$PageContent$loginControl$btnLogin"]');
logger.info('Clicking login button...');
await loginButton.click();
await page.waitForLoadState('networkidle', { timeout: 30000 });
const isLoggedIn = await checkLoginSuccess(page);
if (!isLoggedIn) {
const errorMessage = await page.locator('.error, .errorMessage, [class*="error"]').first().textContent();
throw new Error(`Login failed. Possible error: ${errorMessage || 'Unknown error'}`);
}
logger.info('Login successful! Extracting cookies...');
const cookies = await context.cookies();
logger.info(`Extracted ${cookies.length} cookies`);
await saveCookiesToCache(cookies);
logImportantCookies(cookies);
await browser.close();
return cookies;
} catch (error) {
logger.error('Error during Playwright login:', error);
await browser.close();
throw error;
}
}
/**
* Check if login was successful
*/
async function checkLoginSuccess(page: any): Promise<boolean> {
await page.waitForTimeout(1000);
const currentUrl = page.url();
const notOnLoginPage = !currentUrl.includes('Login.aspx');
const hasLogoutLink = await page.locator('text=Logout, text=退出text=Sign Out').count() > 0;
const hasWelcomeText = await page.locator('text=Welcome, text=欢迎').count() > 0;
return notOnLoginPage || hasLogoutLink || hasWelcomeText;
}
/**
* Log important cookies for debugging
*/
function logImportantCookies(cookies: Cookie[]): void {
const importantCookieNames = [
'ASP.NET_SessionId',
'.ASPXFORMSAUTH',
];
logger.debug('Important cookies:');
cookies.forEach(cookie => {
if (importantCookieNames.some(name => cookie.name.includes(name))) {
logger.debug(` ${cookie.name}: ${cookie.value.substring(0, 50)}${cookie.value.length > 50 ? '...' : ''}`);
}
});
}
/**
* Load cookies from cache file
*/
export async function loadCachedCookies(): Promise<Cookie[] | null> {
if (_inMemoryCookies) {
logger.debug('Using in-memory cached cookies.');
return _inMemoryCookies;
}
if (!fs.existsSync(COOKIE_FILE_PATH)) {
logger.debug('Cookie cache file not found. No cached cookies loaded.');
return null;
}
try {
const cookies = JSON.parse(fs.readFileSync(COOKIE_FILE_PATH, 'utf-8')) as Cookie[];
_inMemoryCookies = cookies;
logger.debug(`Loaded ${cookies.length} cookies from file cache.`);
return cookies;
} catch (error: any) {
logger.warn('Error loading cookies from file:', error.message);
return null;
}
}
/**
* Save cookies to cache file
*/
export async function saveCookiesToCache(cookies: Cookie[]): Promise<void> {
if (!cookies || cookies.length === 0) {
logger.warn('Attempted to save empty or null cookies. Aborting save.');
return;
}
_inMemoryCookies = cookies;
try {
await fs.promises.writeFile(COOKIE_FILE_PATH, JSON.stringify(cookies, null, 2), 'utf-8');
logger.debug('Cookies saved to file cache.');
} catch (error: any) {
logger.error('Error saving cookies to file:', error.message);
}
}
/**
* Clear cookie cache
*/
export async function clearCookieCache(): Promise<void> {
_inMemoryCookies = null;
try {
await fs.promises.unlink(COOKIE_FILE_PATH);
logger.debug('Cookie cache file deleted.');
} catch (error: any) {
if (error.code !== 'ENOENT') {
logger.error('Error deleting cookie file:', error.message);
} else {
logger.debug('Cookie cache file did not exist, no need to delete.');
}
}
}
/**
* Convert cookies array to cookie string for axios
*/
export function cookiesToString(cookies: Cookie[]): string {
return cookies.map(c => `${c.name}=${c.value}`).join('; ');
}
/**
* Get cookie string from cache
*/
export async function getCachedCookieString(): Promise<string | null> {
const cookies = await loadCachedCookies();
if (!cookies || cookies.length === 0) {
return null;
}
return cookiesToString(cookies);
}

View File

@@ -15,6 +15,7 @@ const S3_REGION = process.env.S3_REGION;
const S3_ACCESS_KEY_ID = process.env.S3_ACCESS_KEY_ID;
const S3_SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY;
const BUCKET_NAME = process.env.S3_BUCKET_NAME;
const S3_PUBLIC_URL = process.env.S3_PUBLIC_URL;
const PUBLIC_URL_FILE_PREFIX = (process.env.S3_PUBLIC_URL_PREFIX || 'files').replace(/\/$/, '');
// Initialize S3 client
@@ -195,6 +196,7 @@ export async function deleteS3Objects(objectKeysArray: string[]): Promise<boolea
/**
* Constructs the public S3 URL for an object key.
* Uses S3_PUBLIC_URL if set (reverse proxy scenario), otherwise uses S3_ENDPOINT.
* @param objectKey - The key of the object in S3
* @returns The full public URL
*/
@@ -202,8 +204,8 @@ export function constructS3Url(objectKey: string): string {
if (!S3_ENDPOINT || !BUCKET_NAME) {
return '';
}
// Ensure S3_ENDPOINT does not end with a slash
const s3Base = S3_ENDPOINT.replace(/\/$/, '');
// Use S3_PUBLIC_URL if set (reverse proxy), otherwise use S3_ENDPOINT
const s3Base = (S3_PUBLIC_URL || S3_ENDPOINT).replace(/\/$/, '');
// Ensure BUCKET_NAME does not start or end with a slash
const bucket = BUCKET_NAME.replace(/^\//, '').replace(/\/$/, '');
// Ensure objectKey does not start with a slash

25
startup.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/bin/sh
set -e
echo "🚀 Starting DSAS CCA Backend..."
# Check if cookies exist and are valid
if [ -f /usr/src/app/services/cookies.json ]; then
echo "📁 Cookies file found. Checking validity..."
# Try to fetch a simple activity to test cookies
# If it fails, we'll get fresh cookies
if ! timeout 10 bun run test/test-cookies-validity.ts 2>/dev/null; then
echo "⚠️ Cookies are invalid or expired. Getting fresh cookies..."
bun run test/get-cookies.ts
else
echo "✅ Cookies are valid. Using cached cookies."
fi
else
echo "📁 No cookies file found. Getting fresh cookies..."
bun run test/get-cookies.ts
fi
# Start the application
echo "🎯 Starting application..."
exec bun run index.ts

118
test/auth.spec.ts Normal file
View File

@@ -0,0 +1,118 @@
import { test, expect } from 'bun:test';
import { chromium, type Cookie } from 'playwright';
import * as fs from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const COOKIE_FILE_PATH = resolve(__dirname, '../services/cookies.json');
const testUsername = process.env.API_USERNAME || 'test@test.com';
const testPassword = process.env.API_PASSWORD || 'test123';
test('should login and extract cookies successfully', async () => {
const browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
try {
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
});
const page = await context.newPage();
await page.goto('https://engage.nkcswx.cn/Login.aspx', {
waitUntil: 'networkidle',
timeout: 60000
});
const usernameField = page.locator('input[name="ctl00$PageContent$loginControl$txtUN"]');
await usernameField.fill(decodeURIComponent(testUsername));
const passwordField = page.locator('input[name="ctl00$PageContent$loginControl$txtPwd"]');
await passwordField.fill(decodeURIComponent(testPassword));
const loginButton = page.locator('input[name="ctl00$PageContent$loginControl$btnLogin"]');
await loginButton.click();
await page.waitForLoadState('networkidle', { timeout: 60000 });
const cookies = await context.cookies();
expect(cookies).toBeDefined();
expect(cookies.length).toBeGreaterThan(0);
const hasSessionCookie = cookies.some(c => c.name === 'ASP.NET_SessionId');
expect(hasSessionCookie).toBe(true);
fs.writeFileSync(COOKIE_FILE_PATH, JSON.stringify(cookies, null, 2));
} finally {
await browser.close();
}
}, 120000);
test('should load cookies from file if exists', () => {
if (!fs.existsSync(COOKIE_FILE_PATH)) {
throw new Error('Cookie file does not exist');
}
const cookies = JSON.parse(fs.readFileSync(COOKIE_FILE_PATH, 'utf-8')) as Cookie[];
expect(cookies.length).toBeGreaterThan(0);
});
test('should test cookie validity', async () => {
if (!fs.existsSync(COOKIE_FILE_PATH)) {
throw new Error('Cookie file does not exist');
}
const cookies = JSON.parse(fs.readFileSync(COOKIE_FILE_PATH, 'utf-8')) as Cookie[];
const browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
try {
const context = await browser.newContext();
await context.addCookies(cookies);
const page = await context.newPage();
await page.goto('https://engage.nkcswx.cn/', {
waitUntil: 'networkidle',
timeout: 30000
});
const url = page.url();
const isRedirectedToLogin = url.includes('/Login.aspx');
expect(isRedirectedToLogin).toBe(false);
} finally {
await browser.close();
}
}, 60000);
test('should convert cookies to string format', () => {
if (!fs.existsSync(COOKIE_FILE_PATH)) {
throw new Error('Cookie file does not exist');
}
const cookies = JSON.parse(fs.readFileSync(COOKIE_FILE_PATH, 'utf-8')) as Cookie[];
const cookieString = cookies.map(c => `${c.name}=${c.value}`).join('; ');
expect(cookieString).toBeDefined();
expect(cookieString.length).toBeGreaterThan(0);
expect(cookieString).toContain('ASP.NET_SessionId=');
});
test('should clear cookie cache', () => {
if (fs.existsSync(COOKIE_FILE_PATH)) {
fs.unlinkSync(COOKIE_FILE_PATH);
}
const exists = fs.existsSync(COOKIE_FILE_PATH);
expect(exists).toBe(false);
});

24
test/get-cookies.ts Normal file
View File

@@ -0,0 +1,24 @@
import { loginWithPlaywright, saveCookiesToCache } from '../services/playwright-auth';
const username = process.env.API_USERNAME;
const password = process.env.API_PASSWORD;
if (!username || !password) {
console.error('❌ API_USERNAME and API_PASSWORD environment variables are required');
process.exit(1);
}
console.log('🔑 Starting cookie extraction...\n');
loginWithPlaywright(username, password)
.then(cookies => {
console.log(`\n✅ Extracted ${cookies.length} cookies`);
console.log('📁 Cookies saved to: ./services/cookies.json');
saveCookiesToCache(cookies);
process.exit(0);
})
.catch(error => {
console.error('\n❌ Cookie extraction failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,35 @@
import axios from 'axios';
const COOKIE_FILE = './services/cookies.json';
async function testCookies() {
try {
const fs = await import('fs');
if (!fs.existsSync(COOKIE_FILE)) {
return false;
}
const cookies = JSON.parse(fs.readFileSync(COOKIE_FILE, 'utf-8'));
const cookieString = cookies.map((c: any) => `${c.name}=${c.value}`).join('; ');
const url = 'https://engage.nkcswx.cn/Services/ActivitiesService.asmx/GetActivityDetails';
const headers = {
'Content-Type': 'application/json; charset=UTF-8',
'Cookie': cookieString,
'User-Agent': 'Mozilla/5.0 (Bun DSAS-CCA)',
};
const payload = { "activityID": "3350" };
await axios.post(url, payload, {
headers,
timeout: 10000
});
return true;
} catch (error) {
return false;
}
}
const isValid = await testCookies();
process.exit(isValid ? 0 : 1);