@restnfeel/agentc-starter-kit
Version:
한국어 기업용 CMS 모듈 - Task Master AI와 함께 빠르게 웹사이트를 구현할 수 있는 재사용 가능한 컴포넌트 시스템
305 lines (263 loc) • 7.22 kB
text/typescript
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,
});
}