package main import ( "archive/zip" "crypto/sha256" "encoding/hex" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strings" "sync/atomic" "time" ) const maxDownloadSize = 2 << 30 // 2GB const maxExtractFileSize = 4 << 30 // 개별 파일 최대 4GB var downloadCancelled atomic.Bool var checkRedirect = func(req *http.Request, via []*http.Request) error { if req.URL.Scheme != "https" && req.URL.Scheme != "http" { return fmt.Errorf("허용되지 않는 리다이렉트 스킴: %s", req.URL.Scheme) } if len(via) >= 10 { return fmt.Errorf("리다이렉트 횟수 초과") } return nil } // apiClient: 서버 정보 조회 등 짧은 요청용 (전체 120초 타임아웃) var apiClient = &http.Client{ Timeout: 120 * time.Second, CheckRedirect: checkRedirect, } // downloadClient: 대용량 파일 다운로드용 (연결 30초 + 유휴 60초, 전체 타임아웃 없음) var downloadClient = &http.Client{ Transport: &http.Transport{ TLSHandshakeTimeout: 30 * time.Second, ResponseHeaderTimeout: 30 * time.Second, IdleConnTimeout: 60 * time.Second, }, CheckRedirect: checkRedirect, } // doDownloadRequest sends a GET (with Range if a partial file exists). // If the server replies 416, it deletes the stale temp file and retries once. func doDownloadRequest(downloadURL, tmpPath string) (*http.Response, error) { for attempt := 0; attempt < 2; attempt++ { var resumeOffset int64 if fi, err := os.Stat(tmpPath); err == nil { resumeOffset = fi.Size() } req, err := http.NewRequest("GET", downloadURL, nil) if err != nil { return nil, fmt.Errorf("다운로드 요청 생성 실패: %w", err) } if resumeOffset > 0 { req.Header.Set("Range", fmt.Sprintf("bytes=%d-", resumeOffset)) } resp, err := downloadClient.Do(req) if err != nil { return nil, fmt.Errorf("다운로드 연결 실패: %w", err) } if resp.StatusCode == http.StatusRequestedRangeNotSatisfiable { resp.Body.Close() os.Remove(tmpPath) continue } return resp, nil } return nil, fmt.Errorf("다운로드 실패: 재시도 횟수 초과") } func doDownload(downloadURL, destDir string) error { tmpPath := filepath.Join(os.TempDir(), "a301_game.zip") resp, err := doDownloadRequest(downloadURL, tmpPath) if err != nil { return err } defer resp.Body.Close() var downloaded int64 var total int64 var tmpFile *os.File var resumeOffset int64 if fi, statErr := os.Stat(tmpPath); statErr == nil { resumeOffset = fi.Size() } switch resp.StatusCode { case http.StatusPartialContent: downloaded = resumeOffset if resp.ContentLength > 0 { total = resumeOffset + resp.ContentLength } tmpFile, err = os.OpenFile(tmpPath, os.O_WRONLY|os.O_APPEND, 0644) case http.StatusOK: if resp.ContentLength > 0 { total = resp.ContentLength } tmpFile, err = os.Create(tmpPath) default: return fmt.Errorf("다운로드 실패 (HTTP %d)", resp.StatusCode) } if err != nil { return fmt.Errorf("임시 파일 열기 실패: %w", err) } if total > maxDownloadSize { tmpFile.Close() os.Remove(tmpPath) return fmt.Errorf("파일이 너무 큽니다 (%d bytes)", total) } buf := make([]byte, 32*1024) var lastSpeedUpdate time.Time var lastBytes int64 var speedBytesPerSec float64 for { if downloadCancelled.Load() { tmpFile.Close() return fmt.Errorf("다운로드가 취소되었습니다") } n, err := resp.Body.Read(buf) if n > 0 { if _, werr := tmpFile.Write(buf[:n]); werr != nil { tmpFile.Close() return fmt.Errorf("파일 쓰기 실패: %w", werr) } downloaded += int64(n) if downloaded > maxDownloadSize { tmpFile.Close() os.Remove(tmpPath) return fmt.Errorf("다운로드 크기가 제한을 초과했습니다") } now := time.Now() if now.Sub(lastSpeedUpdate) >= 500*time.Millisecond { elapsed := now.Sub(lastSpeedUpdate).Seconds() if elapsed > 0 { speedBytesPerSec = float64(downloaded-lastBytes) / elapsed } lastBytes = downloaded lastSpeedUpdate = now } if total > 0 { pct := int(downloaded * 100 / total) if pct > 100 { pct = 100 } speedMB := speedBytesPerSec / 1024 / 1024 text := fmt.Sprintf("다운로드 중... %d%% (%.1f MB/s)", pct, speedMB) if speedBytesPerSec > 0 { remaining := float64(total-downloaded) / speedBytesPerSec if remaining < 60 { text = fmt.Sprintf("다운로드 중... %d%% (%.1f MB/s, %d초 남음)", pct, speedMB, int(remaining)) } else { text = fmt.Sprintf("다운로드 중... %d%% (%.1f MB/s, %d분 남음)", pct, speedMB, int(remaining/60)) } } setProgress(text, pct) } } if err == io.EOF { break } if err != nil { tmpFile.Close() return fmt.Errorf("다운로드 중 오류: %w", err) } } tmpFile.Close() defer os.Remove(tmpPath) setProgress("압축을 해제하는 중...", -1) tmpExtractDir, err := os.MkdirTemp("", "a301_extract_") if err != nil { return fmt.Errorf("임시 추출 디렉토리 생성 실패: %w", err) } if err := extractZip(tmpPath, tmpExtractDir); err != nil { os.RemoveAll(tmpExtractDir) return err } if err := moveContents(tmpExtractDir, destDir); err != nil { os.RemoveAll(tmpExtractDir) return fmt.Errorf("파일 이동 실패: %w", err) } os.RemoveAll(tmpExtractDir) return nil } func extractZip(zipPath, destDir string) error { r, err := zip.OpenReader(zipPath) if err != nil { return fmt.Errorf("zip 열기 실패: %w", err) } defer r.Close() selfName := strings.ToLower(filepath.Base(os.Args[0])) for _, f := range r.File { clean := filepath.ToSlash(f.Name) parts := strings.SplitN(clean, "/", 2) var rel string if len(parts) == 2 && parts[1] != "" { rel = parts[1] } else if len(parts) == 1 && !strings.Contains(clean, "/") && parts[0] != "" { rel = parts[0] } else { continue } if filepath.IsAbs(rel) { return fmt.Errorf("잘못된 zip 경로 (절대 경로): %s", rel) } if strings.Contains(rel, ":") { return fmt.Errorf("잘못된 zip 경로 (ADS): %s", rel) } if strings.ToLower(filepath.Base(rel)) == selfName { continue } dest := filepath.Join(destDir, filepath.FromSlash(rel)) cleanDest := filepath.Clean(dest) cleanBase := filepath.Clean(destDir) + string(os.PathSeparator) if !strings.HasPrefix(cleanDest, cleanBase) && cleanDest != filepath.Clean(destDir) { return fmt.Errorf("잘못된 zip 경로: %s", rel) } if f.FileInfo().Mode()&os.ModeSymlink != 0 { continue } if f.FileInfo().IsDir() { os.MkdirAll(dest, 0755) continue } if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { return err } rc, err := f.Open() if err != nil { return err } out, err := os.Create(dest) if err != nil { rc.Close() return err } _, err = io.Copy(out, io.LimitReader(rc, maxExtractFileSize)) closeErr := out.Close() rc.Close() if err != nil { return err } if closeErr != nil { return fmt.Errorf("파일 닫기 실패: %w", closeErr) } } return nil } func moveContents(srcDir, dstDir string) error { entries, err := os.ReadDir(srcDir) if err != nil { return err } for _, e := range entries { src := filepath.Join(srcDir, e.Name()) dst := filepath.Join(dstDir, e.Name()) if e.IsDir() { if err := os.MkdirAll(dst, 0755); err != nil { return err } if err := moveContents(src, dst); err != nil { return err } } else { os.Remove(dst) if err := os.Rename(src, dst); err != nil { if err := copyFile(src, dst); err != nil { os.Remove(dst) return err } os.Remove(src) } } } return nil } func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } if _, err = io.Copy(out, in); err != nil { out.Close() return err } return out.Close() } func hashFile(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { return "", err } return hex.EncodeToString(h.Sum(nil)), nil } func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error { if serverInfo.FileHash == "" { return fmt.Errorf("서버에서 유효한 파일 정보를 받지 못했습니다") } needsDownload := false if _, err := os.Stat(gamePath); os.IsNotExist(err) { needsDownload = true } else if err != nil { return fmt.Errorf("게임 파일 확인 실패: %w", err) } else { localHash, err := hashFile(gamePath) if err != nil { return fmt.Errorf("파일 검증 실패: %w", err) } if !strings.EqualFold(localHash, serverInfo.FileHash) { needsDownload = true } } if needsDownload { if serverInfo.URL == "" { return fmt.Errorf("다운로드 URL이 없습니다") } u, err := url.Parse(serverInfo.URL) if err != nil || (u.Scheme != "https" && u.Scheme != "http") { return fmt.Errorf("유효하지 않은 다운로드 URL") } if err := downloadWithProgress(serverInfo.URL, gameDir); err != nil { return fmt.Errorf("게임 설치 실패: %w", err) } if serverInfo.FileHash != "" { newHash, err := hashFile(gamePath) if err != nil { return fmt.Errorf("다운로드 후 파일 검증 실패: %w", err) } if !strings.EqualFold(newHash, serverInfo.FileHash) { os.Remove(gamePath) return fmt.Errorf("다운로드된 파일의 해시가 일치하지 않습니다 (파일이 손상되었을 수 있습니다)") } } } return nil } // downloadFile downloads a file from url to destPath using apiClient. func downloadFile(dlURL, destPath string) error { resp, err := apiClient.Get(dlURL) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP %d", resp.StatusCode) } f, err := os.Create(destPath) if err != nil { return err } _, err = io.Copy(f, io.LimitReader(resp.Body, maxDownloadSize)) if closeErr := f.Close(); closeErr != nil && err == nil { err = closeErr } return err } // ensureLauncher checks if the installed launcher is up-to-date and replaces it if not. func ensureLauncher(serverInfo *downloadInfo) (updated bool, err error) { if serverInfo.LauncherHash == "" { return false, nil } installedPath, err := launcherPath() if err != nil { return false, nil } localHash, err := hashFile(installedPath) if err != nil { return false, nil } if strings.EqualFold(localHash, serverInfo.LauncherHash) { return false, nil } dlURL := serverInfo.LauncherURL if dlURL == "" { return false, nil } newPath := installedPath + ".new" if err := downloadFile(dlURL, newPath); err != nil { os.Remove(newPath) return false, fmt.Errorf("런처 업데이트 다운로드 실패: %w", err) } newHash, err := hashFile(newPath) if err != nil { os.Remove(newPath) return false, fmt.Errorf("런처 검증 실패: %w", err) } if !strings.EqualFold(newHash, serverInfo.LauncherHash) { os.Remove(newPath) return false, fmt.Errorf("런처 해시 불일치") } oldPath := installedPath + ".old" os.Remove(oldPath) if err := os.Rename(installedPath, oldPath); err != nil { os.Remove(newPath) return false, fmt.Errorf("런처 교체 실패: %w", err) } if err := os.Rename(newPath, installedPath); err != nil { if restoreErr := os.Rename(oldPath, installedPath); restoreErr != nil { return false, fmt.Errorf("런처 교체 실패 및 복원 불가: %w (원인: %v)", restoreErr, err) } return false, fmt.Errorf("런처 교체 실패: %w", err) } return true, nil } // cleanupOldFiles removes .old and .new leftover files from previous launcher updates. func cleanupOldFiles(dir string) { entries, err := os.ReadDir(dir) if err != nil { return } for _, e := range entries { name := e.Name() if strings.HasSuffix(name, ".old") || strings.HasSuffix(name, ".new") { os.Remove(filepath.Join(dir, name)) } } }