package download import ( "archive/zip" "crypto/sha256" "encoding/hex" "fmt" "io" "log" "os" "path/filepath" "regexp" "strings" "sync" ) const maxLauncherSize = 500 * 1024 * 1024 // 500MB var versionRe = regexp.MustCompile(`v\d+\.\d+(\.\d+)?`) type Service struct { repo *Repository gameDir string uploadMu sync.Mutex } func NewService(repo *Repository, gameDir string) *Service { return &Service{repo: repo, gameDir: gameDir} } func (s *Service) GetInfo() (*Info, error) { return s.repo.GetLatest() } func (s *Service) GameFilePath() string { return filepath.Join(s.gameDir, "game.zip") } func (s *Service) LauncherFilePath() string { return filepath.Join(s.gameDir, "launcher.exe") } func (s *Service) UploadLauncher(body io.Reader, baseURL string) (*Info, error) { s.uploadMu.Lock() defer s.uploadMu.Unlock() if err := os.MkdirAll(s.gameDir, 0755); err != nil { return nil, fmt.Errorf("디렉토리 생성 실패: %w", err) } finalPath := s.LauncherFilePath() tmpPath := finalPath + ".tmp" f, err := os.Create(tmpPath) if err != nil { return nil, fmt.Errorf("파일 생성 실패: %w", err) } n, err := io.Copy(f, io.LimitReader(body, maxLauncherSize+1)) if closeErr := f.Close(); closeErr != nil && err == nil { err = closeErr } if err != nil { if removeErr := os.Remove(tmpPath); removeErr != nil { log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr) } return nil, fmt.Errorf("파일 저장 실패: %w", err) } if n > maxLauncherSize { os.Remove(tmpPath) return nil, fmt.Errorf("런처 파일이 너무 큽니다 (최대 %dMB)", maxLauncherSize/1024/1024) } // PE 헤더 검증 (MZ magic bytes) if err := validatePEHeader(tmpPath); err != nil { os.Remove(tmpPath) return nil, err } if err := os.Rename(tmpPath, finalPath); err != nil { if removeErr := os.Remove(tmpPath); removeErr != nil { log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr) } return nil, fmt.Errorf("파일 이동 실패: %w", err) } launcherSize := "" if n > 0 { launcherSize = fmt.Sprintf("%.1f MB", float64(n)/1024/1024) } launcherHash := hashFileToHex(finalPath) info, err := s.repo.GetLatest() if err != nil { info = &Info{} } info.LauncherURL = baseURL + "/api/download/launcher" info.LauncherSize = launcherSize info.LauncherHash = launcherHash return info, s.repo.Save(info) } // Upload streams the body directly to disk, then extracts metadata from the zip. func (s *Service) Upload(filename string, body io.Reader, baseURL string) (*Info, error) { s.uploadMu.Lock() defer s.uploadMu.Unlock() if err := os.MkdirAll(s.gameDir, 0755); err != nil { return nil, fmt.Errorf("디렉토리 생성 실패: %w", err) } finalPath := s.GameFilePath() tmpPath := finalPath + ".tmp" f, err := os.Create(tmpPath) if err != nil { return nil, fmt.Errorf("파일 생성 실패: %w", err) } n, err := io.Copy(f, body) if closeErr := f.Close(); closeErr != nil && err == nil { err = closeErr } if err != nil { if removeErr := os.Remove(tmpPath); removeErr != nil { log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr) } return nil, fmt.Errorf("파일 저장 실패: %w", err) } if err := os.Rename(tmpPath, finalPath); err != nil { if removeErr := os.Remove(tmpPath); removeErr != nil { log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr) } return nil, fmt.Errorf("파일 이동 실패: %w", err) } version := "" if m := versionRe.FindString(filename); m != "" { version = m } fileSize := "" if n > 0 { mb := float64(n) / 1024 / 1024 if mb >= 1000 { fileSize = fmt.Sprintf("%.1f GB", mb/1024) } else { fileSize = fmt.Sprintf("%.1f MB", mb) } } fileHash := hashGameExeFromZip(finalPath) if fileHash == "" { if removeErr := os.Remove(finalPath); removeErr != nil { log.Printf("WARNING: failed to remove file %s: %v", finalPath, removeErr) } return nil, fmt.Errorf("zip 파일에 %s이(가) 포함되어 있지 않습니다", "A301.exe") } info, err := s.repo.GetLatest() if err != nil { info = &Info{} } info.URL = baseURL + "/api/download/file" info.Version = version info.FileName = filename info.FileSize = fileSize info.FileHash = fileHash return info, s.repo.Save(info) } func validatePEHeader(path string) error { f, err := os.Open(path) if err != nil { return fmt.Errorf("파일 검증 실패: %w", err) } defer f.Close() header := make([]byte, 2) if _, err := io.ReadFull(f, header); err != nil { return fmt.Errorf("유효하지 않은 실행 파일입니다") } if header[0] != 'M' || header[1] != 'Z' { return fmt.Errorf("유효하지 않은 실행 파일입니다") } return nil } func hashFileToHex(path string) string { f, err := os.Open(path) if err != nil { return "" } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { return "" } return hex.EncodeToString(h.Sum(nil)) } const maxExeSize = 100 * 1024 * 1024 // 100MB — Zip Bomb 방어 func hashGameExeFromZip(zipPath string) string { r, err := zip.OpenReader(zipPath) if err != nil { return "" } defer r.Close() for _, f := range r.File { if strings.EqualFold(filepath.Base(f.Name), "A301.exe") { rc, err := f.Open() if err != nil { return "" } h := sha256.New() _, err = io.Copy(h, io.LimitReader(rc, maxExeSize)) rc.Close() if err != nil { return "" } return hex.EncodeToString(h.Sum(nil)) } } return "" }