- Move test files to lib/__tests__/ directory - Extract formatting utilities (formatVersion, formatFileSize, formatDateTime) from fs-utils.ts to new utils.ts module - Add Jest test configuration and test scripts - Update component imports to use new utils module - Add CLAUDE.md documentation for project structure Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
104 lines
2.9 KiB
TypeScript
104 lines
2.9 KiB
TypeScript
// lib/fs-utils.ts
|
||
import fs from 'fs/promises';
|
||
import path from 'path';
|
||
import { RESOURCE_PATH } from './constants';
|
||
|
||
/**
|
||
* Validate that a path doesn't escape the base directory (prevents path traversal)
|
||
*/
|
||
export function validatePath(basePath: string, ...segments: string[]): string {
|
||
const fullPath = path.resolve(basePath, ...segments);
|
||
const normalizedBase = path.resolve(basePath);
|
||
if (!fullPath.startsWith(normalizedBase)) {
|
||
throw new Error('Invalid path: path traversal detected');
|
||
}
|
||
return fullPath;
|
||
}
|
||
|
||
/**
|
||
* 获取所有项目目录
|
||
*/
|
||
export async function getProjects(): Promise<string[]> {
|
||
const dirs = await fs.readdir(RESOURCE_PATH);
|
||
const projectDirs = await Promise.all(
|
||
dirs.map(async (dir) => {
|
||
const dirPath = validatePath(RESOURCE_PATH, dir);
|
||
try {
|
||
const stats = await fs.stat(dirPath);
|
||
return stats.isDirectory() ? dir : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
})
|
||
);
|
||
return projectDirs.filter((dir): dir is string => dir !== null);
|
||
}
|
||
|
||
/**
|
||
* 获取指定项目的所有版本目录
|
||
*/
|
||
export async function getVersions(project: string): Promise<string[]> {
|
||
const projectPath = validatePath(RESOURCE_PATH, project);
|
||
const dirs = await fs.readdir(projectPath);
|
||
const versionDirs = await Promise.all(
|
||
dirs.map(async (dir) => {
|
||
const dirPath = validatePath(projectPath, dir);
|
||
try {
|
||
const stats = await fs.stat(dirPath);
|
||
return stats.isDirectory() ? dir : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
})
|
||
);
|
||
return versionDirs.filter((dir): dir is string => dir !== null).sort();
|
||
}
|
||
|
||
/**
|
||
* 获取指定项目版本的 APK 文件列表
|
||
*/
|
||
export async function getApks(project: string, version: string, commitId?: string): Promise<{
|
||
name: string;
|
||
commit: string | null;
|
||
size: number;
|
||
modifiedAt: Date;
|
||
}[]> {
|
||
const apksPath = validatePath(RESOURCE_PATH, project, version, 'apks');
|
||
|
||
try {
|
||
const files = await fs.readdir(apksPath);
|
||
const apkFiles = files.filter(file => file.endsWith('.apk'));
|
||
|
||
const apks = await Promise.all(
|
||
apkFiles.map(async (file) => {
|
||
const filePath = validatePath(apksPath, file);
|
||
const stats = await fs.stat(filePath);
|
||
|
||
// 提取 commit id (假设文件名格式为 xxx-{commit}-xxx.apk)
|
||
const commitMatch = file.match(/([a-f0-9]{7,})/i);
|
||
const commit = commitMatch ? commitMatch[1] : null;
|
||
|
||
return {
|
||
name: file,
|
||
commit,
|
||
size: stats.size,
|
||
modifiedAt: stats.mtime,
|
||
};
|
||
})
|
||
);
|
||
|
||
// 如果指定了 commit id,进行过滤
|
||
if (commitId) {
|
||
return apks.filter(apk =>
|
||
apk.commit && apk.commit.toLowerCase().includes(commitId.toLowerCase())
|
||
);
|
||
}
|
||
|
||
return apks.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime());
|
||
} catch (error) {
|
||
// 如果 apks 目录不存在,返回空数组
|
||
return [];
|
||
}
|
||
}
|
||
|