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 // 2 GB const maxExtractFileSize = 4 << 30 // 개별 파일 최대 4 GB const tmpZipName = "a301_game.zip" // 임시 다운로드 파일명 var downloadCancelled atomic.Bool // ── HTTP 클라이언트 ────────────────────────────────────────── // checkRedirect 허용되지 않는 스킴이나 과도한 리다이렉트를 차단한다. 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 대용량 파일 다운로드용 (전체 타임아웃 없음). var downloadClient = &http.Client{ Transport: &http.Transport{ TLSHandshakeTimeout: 30 * time.Second, ResponseHeaderTimeout: 30 * time.Second, IdleConnTimeout: 60 * time.Second, }, CheckRedirect: checkRedirect, } // ── 파일 다운로드 ──────────────────────────────────────────── // doDownloadRequest Range 헤더로 이어받기를 시도한다. // 서버가 416(범위 불일치)을 반환하면 임시 파일을 삭제하고 처음부터 다시 요청한다. func doDownloadRequest(downloadURL, tmpPath string) (resp *http.Response, resumeOffset int64, err error) { for attempt := 0; attempt < 2; attempt++ { resumeOffset = 0 if fi, statErr := os.Stat(tmpPath); statErr == nil { resumeOffset = fi.Size() } req, err := http.NewRequest("GET", downloadURL, nil) if err != nil { return nil, 0, 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, 0, fmt.Errorf("다운로드 연결 실패: %w", err) } if resp.StatusCode == http.StatusRequestedRangeNotSatisfiable { resp.Body.Close() os.Remove(tmpPath) continue } return resp, resumeOffset, nil } return nil, 0, fmt.Errorf("다운로드 실패: 재시도 횟수 초과") } // openTmpFile 응답 상태에 따라 임시 파일을 이어쓰기 또는 새로 생성한다. func openTmpFile(resp *http.Response, tmpPath string, resumeOffset int64) (tmpFile *os.File, downloaded, total int64, err error) { 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 nil, 0, 0, fmt.Errorf("다운로드 실패 (HTTP %d)", resp.StatusCode) } if err != nil { return nil, 0, 0, fmt.Errorf("임시 파일 열기 실패: %w", err) } if total > maxDownloadSize { tmpFile.Close() os.Remove(tmpPath) return nil, 0, 0, fmt.Errorf("파일이 너무 큽니다 (%d bytes)", total) } return tmpFile, downloaded, total, nil } // formatProgress 다운로드 진행률 텍스트를 생성한다. func formatProgress(pct int, speedBytesPerSec float64, remaining float64) string { if speedBytesPerSec <= 0 { return fmt.Sprintf("다운로드 중... %d%%", pct) } speedMB := speedBytesPerSec / 1024 / 1024 if remaining < 60 { return fmt.Sprintf("다운로드 중... %d%% (%.1f MB/s, %d초 남음)", pct, speedMB, int(remaining)) } return fmt.Sprintf("다운로드 중... %d%% (%.1f MB/s, %d분 남음)", pct, speedMB, int(remaining/60)) } // downloadBody 응답 본문을 tmpFile에 쓰고 진행률을 갱신한다. // downloaded는 이어받기 시작 오프셋, total은 전체 크기(미확정이면 0). // tmpPath는 크기 초과 시 파일 삭제에만 사용된다. // 완료 또는 오류 시 tmpFile을 닫는다. func downloadBody(resp *http.Response, tmpFile *os.File, tmpPath string, downloaded, total int64) error { 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, readErr := 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("다운로드 크기가 제한을 초과했습니다") } // 500ms마다 속도 계산 및 진행률 갱신 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 } remaining := float64(total-downloaded) / speedBytesPerSec setProgress(formatProgress(pct, speedBytesPerSec, remaining), pct) } } if readErr == io.EOF { break } if readErr != nil { tmpFile.Close() return fmt.Errorf("다운로드 중 오류: %w", readErr) } } return tmpFile.Close() } // doDownload 파일을 다운로드하고 zip을 추출하여 destDir에 배치한다. // 다운로드 완료 후 zip 파일은 tmpZipName 경로에 남겨둔다 (ensureGame에서 해시 검증 후 삭제). func doDownload(downloadURL, destDir string) error { tmpPath := filepath.Join(os.TempDir(), tmpZipName) resp, resumeOffset, err := doDownloadRequest(downloadURL, tmpPath) if err != nil { return err } defer resp.Body.Close() tmpFile, downloaded, total, err := openTmpFile(resp, tmpPath, resumeOffset) if err != nil { return err } if err := downloadBody(resp, tmpFile, tmpPath, downloaded, total); err != nil { return err } setProgress("압축을 해제하는 중...", -1) tmpExtractDir, err := os.MkdirTemp("", "a301_extract_") if err != nil { return fmt.Errorf("임시 추출 디렉토리 생성 실패: %w", err) } defer os.RemoveAll(tmpExtractDir) if err := extractZip(tmpPath, tmpExtractDir); err != nil { return err } if err := moveContents(tmpExtractDir, destDir); err != nil { return fmt.Errorf("파일 이동 실패: %w", err) } return nil } // ── zip 추출 ───────────────────────────────────────────────── // extractZip zip 파일을 destDir에 추출한다. // zip에 단일 최상위 래퍼 디렉토리가 있으면 1단계 제거하고, launcher.exe 자신은 덮어쓰기 방지. func extractZip(zipPath, destDir string) error { r, err := zip.OpenReader(zipPath) if err != nil { return fmt.Errorf("zip 열기 실패: %w", err) } defer r.Close() strip := hasWrapperDir(r.File) selfName := strings.ToLower(filepath.Base(os.Args[0])) for _, f := range r.File { rel := resolveZipEntry(f.Name, strip) if rel == "" { continue } // 보안 검증: 절대 경로, ADS, 경로 탈출(zip slip) 차단 if filepath.IsAbs(rel) { return fmt.Errorf("잘못된 zip 경로 (절대 경로): %s", rel) } if strings.Contains(rel, ":") { return fmt.Errorf("잘못된 zip 경로 (ADS): %s", rel) } dest := filepath.Join(destDir, filepath.FromSlash(rel)) if !strings.HasPrefix(filepath.Clean(dest), filepath.Clean(destDir)+string(os.PathSeparator)) && filepath.Clean(dest) != filepath.Clean(destDir) { return fmt.Errorf("잘못된 zip 경로: %s", rel) } // 자기 자신(launcher.exe)은 덮어쓰지 않음 if strings.ToLower(filepath.Base(rel)) == selfName { continue } // 심볼릭 링크는 건너뜀 if f.FileInfo().Mode()&os.ModeSymlink != 0 { continue } if f.FileInfo().IsDir() { os.MkdirAll(dest, 0755) continue } if err := extractFile(f, dest); err != nil { return err } } return nil } // hasWrapperDir zip의 모든 엔트리가 동일한 단일 최상위 디렉토리 아래에 있는지 확인한다. // 예: "game/A301.exe", "game/Data/" → true ("game"이 래퍼) // "A301.exe", "A301_Data/" → false (래퍼 없음) func hasWrapperDir(files []*zip.File) bool { if len(files) == 0 { return false } var wrapper string for _, f := range files { clean := filepath.ToSlash(f.Name) parts := strings.SplitN(clean, "/", 2) top := parts[0] if len(parts) == 1 && !f.FileInfo().IsDir() { // 루트 레벨에 파일이 있으면 래퍼 디렉토리 아님 return false } if wrapper == "" { wrapper = top } else if top != wrapper { // 최상위에 여러 폴더/파일 → 래퍼 아님 return false } } return wrapper != "" } // resolveZipEntry zip 엔트리의 경로를 반환한다. // strip=true이면 최상위 디렉토리를 제거한다. func resolveZipEntry(name string, strip bool) string { clean := filepath.ToSlash(name) if !strip { // 래퍼 없음: 디렉토리 엔트리("/")만 빈 문자열로 반환 clean = strings.TrimSuffix(clean, "/") if clean == "" { return "" } return clean } // 래퍼 제거 parts := strings.SplitN(clean, "/", 2) if len(parts) < 2 || parts[1] == "" { return "" // 래퍼 디렉토리 자체 } return parts[1] } // extractFile 단일 zip 엔트리를 dest 경로에 추출한다. func extractFile(f *zip.File, dest string) error { if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { return err } rc, err := f.Open() if err != nil { return err } defer rc.Close() out, err := os.Create(dest) if err != nil { return err } _, copyErr := io.Copy(out, io.LimitReader(rc, maxExtractFileSize)) closeErr := out.Close() if copyErr != nil { return copyErr } if closeErr != nil { return fmt.Errorf("파일 닫기 실패: %w", closeErr) } return nil } // ── 파일 유틸리티 ──────────────────────────────────────────── // moveContents srcDir의 모든 파일/폴더를 dstDir로 이동한다. // Rename 실패 시 복사 후 원본 삭제로 대체한다. 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 { // 기존 파일 삭제 후 이동. 삭제 실패 시(파일 잠금 등) 에러 반환. if err := os.Remove(dst); err != nil && !os.IsNotExist(err) { return fmt.Errorf("기존 파일 삭제 실패 (%s): %w", e.Name(), err) } 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 } // copyFile src를 dst로 복사한다. 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() } // hashFile 파일의 SHA-256 해시를 계산한다. 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 } // ── 게임/런처 업데이트 ────────────────────────────────────── const hashFileName = ".filehash" // 마지막 설치된 zip 해시를 저장하는 파일 // readLocalHash 로컬에 저장된 마지막 zip 해시를 읽는다. func readLocalHash(gameDir string) string { data, err := os.ReadFile(filepath.Join(gameDir, hashFileName)) if err != nil { return "" } return strings.TrimSpace(string(data)) } // writeLocalHash zip 해시를 로컬에 저장한다. func writeLocalHash(gameDir, hash string) { os.WriteFile(filepath.Join(gameDir, hashFileName), []byte(hash), 0644) } // ensureGame 게임 파일이 최신인지 확인하고 필요 시 다운로드한다. // 서버의 fileHash는 zip 파일 전체의 해시이므로, 로컬에 저장된 해시와 비교한다. func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error { if serverInfo.FileHash == "" { return fmt.Errorf("서버에서 유효한 파일 정보를 받지 못했습니다") } // 게임 파일이 없거나 로컬 해시가 서버와 다르면 다운로드 필요 localHash := readLocalHash(gameDir) if _, err := os.Stat(gamePath); err == nil && strings.EqualFold(localHash, serverInfo.FileHash) { return nil // 게임 파일 존재 + 해시 일치 → 최신 상태 } // URL 검증 후 다운로드 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) } // 다운로드된 zip의 해시를 검증 후 삭제 tmpPath := filepath.Join(os.TempDir(), tmpZipName) defer os.Remove(tmpPath) zipHash, err := hashFile(tmpPath) if err != nil { return fmt.Errorf("다운로드 후 파일 검증 실패: %w", err) } if !strings.EqualFold(zipHash, serverInfo.FileHash) { return fmt.Errorf("다운로드된 파일의 해시가 일치하지 않습니다 (파일이 손상되었을 수 있습니다)") } writeLocalHash(gameDir, serverInfo.FileHash) return nil } // downloadFile url에서 destPath로 파일을 다운로드한다. 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 설치된 런처가 최신인지 확인하고 필요 시 교체한다. // 항상 설치 경로(%LOCALAPPDATA%\A301\launcher.exe)를 대상으로 한다. func ensureLauncher(serverInfo *downloadInfo) (updated bool, err error) { if serverInfo.LauncherHash == "" { return false, nil } dir, err := installDir() if err != nil { return false, nil } installedPath := filepath.Join(dir, "launcher.exe") 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 } // 새 런처를 .new로 다운로드 → 해시 검증 → 기존 파일과 교체 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("런처 해시 불일치") } // 원자적 교체: 기존→.old, .new→기존 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 이전 런처 업데이트에서 남은 .old/.new 파일을 제거한다. 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)) } } }