UNPKG

@restnfeel/agentc-starter-kit

Version:

한국어 기업용 CMS 모듈 - Task Master AI와 함께 빠르게 웹사이트를 구현할 수 있는 재사용 가능한 컴포넌트 시스템

305 lines (263 loc) 7.22 kB
import crypto from "crypto"; export interface SignedUrlOptions { expiresIn: number; // 초 단위 permissions?: string[]; // 허용된 작업 ipRestriction?: string[]; // 허용된 IP 주소 downloadCount?: number; // 최대 다운로드 횟수 metadata?: Record<string, unknown>; } export interface SignedUrlData { fileId: string; permissions: string[]; expiresAt: Date; ipRestriction?: string[]; downloadCount?: number; usedCount: number; metadata?: Record<string, unknown>; createdAt: Date; } export interface SignedUrlResult { signedUrl: string; token: string; expiresAt: Date; permissions: string[]; } export class SignedUrlService { private secretKey: string; private tokenCache: Map<string, SignedUrlData> = new Map(); constructor(secretKey?: string) { this.secretKey = secretKey || process.env.SIGNED_URL_SECRET || "default-secret-key"; } /** * 서명된 URL 생성 */ generateSignedUrl( fileId: string, baseUrl: string, options: SignedUrlOptions ): SignedUrlResult { const expiresAt = new Date(Date.now() + options.expiresIn * 1000); const tokenData: SignedUrlData = { fileId, permissions: options.permissions || ["download"], expiresAt, ipRestriction: options.ipRestriction, downloadCount: options.downloadCount, usedCount: 0, metadata: options.metadata, createdAt: new Date(), }; const token = this.createToken(tokenData); this.tokenCache.set(token, tokenData); // 토큰 만료 시 자동 삭제 setTimeout(() => { this.tokenCache.delete(token); }, options.expiresIn * 1000); const signedUrl = `${baseUrl}?token=${token}`; return { signedUrl, token, expiresAt, permissions: tokenData.permissions, }; } /** * 서명된 URL 검증 */ validateSignedUrl( token: string, clientIp?: string, requiredPermission?: string ): { valid: boolean; data?: SignedUrlData; error?: string; } { const tokenData = this.tokenCache.get(token); if (!tokenData) { return { valid: false, error: "Invalid or expired token" }; } // 만료 시간 확인 if (new Date() > tokenData.expiresAt) { this.tokenCache.delete(token); return { valid: false, error: "Token expired" }; } // IP 제한 확인 if (tokenData.ipRestriction && clientIp) { if (!tokenData.ipRestriction.includes(clientIp)) { return { valid: false, error: "IP address not allowed" }; } } // 다운로드 횟수 제한 확인 if ( tokenData.downloadCount && tokenData.usedCount >= tokenData.downloadCount ) { return { valid: false, error: "Download limit exceeded" }; } // 권한 확인 if ( requiredPermission && !tokenData.permissions.includes(requiredPermission) ) { return { valid: false, error: "Permission denied" }; } return { valid: true, data: tokenData }; } /** * 토큰 사용 횟수 증가 */ incrementUsage(token: string): boolean { const tokenData = this.tokenCache.get(token); if (tokenData) { tokenData.usedCount++; return true; } return false; } /** * 토큰 무효화 */ revokeToken(token: string): boolean { return this.tokenCache.delete(token); } /** * 파일의 모든 토큰 무효화 */ revokeFileTokens(fileId: string): number { let revokedCount = 0; for (const [token, data] of this.tokenCache.entries()) { if (data.fileId === fileId) { this.tokenCache.delete(token); revokedCount++; } } return revokedCount; } /** * 활성 토큰 목록 조회 */ getActiveTokens( fileId?: string ): Array<{ token: string; data: SignedUrlData }> { const activeTokens: Array<{ token: string; data: SignedUrlData }> = []; for (const [token, data] of this.tokenCache.entries()) { if (!fileId || data.fileId === fileId) { if (new Date() <= data.expiresAt) { activeTokens.push({ token, data }); } else { // 만료된 토큰 제거 this.tokenCache.delete(token); } } } return activeTokens; } /** * 토큰 생성 (HMAC 사용) */ private createToken(data: SignedUrlData): string { const payload = JSON.stringify({ fileId: data.fileId, permissions: data.permissions, expiresAt: data.expiresAt.getTime(), ipRestriction: data.ipRestriction, downloadCount: data.downloadCount, }); const signature = crypto .createHmac("sha256", this.secretKey) .update(payload) .digest("hex"); const token = Buffer.from(JSON.stringify({ payload, signature })).toString( "base64url" ); return token; } /** * 토큰 서명 검증 */ private verifyToken(token: string): boolean { try { const decoded = JSON.parse(Buffer.from(token, "base64url").toString()); const { payload, signature } = decoded; const expectedSignature = crypto .createHmac("sha256", this.secretKey) .update(payload) .digest("hex"); return signature === expectedSignature; } catch { return false; } } /** * 토큰 통계 조회 */ getTokenStats(): { totalActive: number; expiringSoon: number; // 1시간 내 만료 totalUsage: number; } { let totalActive = 0; let expiringSoon = 0; let totalUsage = 0; const oneHourFromNow = new Date(Date.now() + 60 * 60 * 1000); for (const data of this.tokenCache.values()) { if (new Date() <= data.expiresAt) { totalActive++; totalUsage += data.usedCount; if (data.expiresAt <= oneHourFromNow) { expiringSoon++; } } } return { totalActive, expiringSoon, totalUsage }; } /** * 만료된 토큰 정리 */ cleanupExpiredTokens(): number { let cleanedCount = 0; const now = new Date(); for (const [token, data] of this.tokenCache.entries()) { if (now > data.expiresAt) { this.tokenCache.delete(token); cleanedCount++; } } return cleanedCount; } } // 싱글톤 인스턴스 let signedUrlServiceInstance: SignedUrlService | null = null; export function getSignedUrlService(): SignedUrlService { if (!signedUrlServiceInstance) { signedUrlServiceInstance = new SignedUrlService(); } return signedUrlServiceInstance; } // 편의 함수들 export function generateTemporaryDownloadUrl( fileId: string, baseUrl: string, expiresInMinutes: number = 60 ): SignedUrlResult { const service = getSignedUrlService(); return service.generateSignedUrl(fileId, baseUrl, { expiresIn: expiresInMinutes * 60, permissions: ["download"], }); } export function generateShareableUrl( fileId: string, baseUrl: string, expiresInHours: number = 24, downloadLimit?: number ): SignedUrlResult { const service = getSignedUrlService(); return service.generateSignedUrl(fileId, baseUrl, { expiresIn: expiresInHours * 60 * 60, permissions: ["view", "download"], downloadCount: downloadLimit, }); }