Files
a301_launcher/download.go
2026-04-13 02:56:10 +09:00

574 lines
17 KiB
Go

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))
}
}
}