From 18c39bd4c5253d96d602a793f48f8e7c26009837 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Tue, 24 Feb 2026 23:25:07 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=A7=81=EC=A0=91=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - zip 스트리밍 업로드 (StreamRequestBody) → /data/game/game.zip 저장 - A301.exe SHA256 해시 자동 추출 (zip 분석) - 버전·파일명·크기 파일명 및 용량에서 자동 추출 - GET /api/download/file 엔드포인트 추가 - BASE_URL, GAME_DIR 환경변수 추가 - Dockerfile에 /data/game 디렉토리 생성 Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 ++ Dockerfile | 1 + internal/download/handler.go | 44 +++++++++++------ internal/download/service.go | 95 +++++++++++++++++++++++++++++++++--- main.go | 8 +-- pkg/config/config.go | 4 ++ routes/routes.go | 3 +- 7 files changed, 132 insertions(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index 0fb34fa..c09b464 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ coverage.html # OS .DS_Store Thumbs.db + +# Game files +*.zip diff --git a/Dockerfile b/Dockerfile index 2a0d523..edad079 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o server . # Stage 2: Run FROM alpine:latest RUN apk --no-cache add tzdata ca-certificates +RUN mkdir -p /data/game WORKDIR /app COPY --from=builder /app/server . EXPOSE 8080 diff --git a/internal/download/handler.go b/internal/download/handler.go index 07175a0..27245e9 100644 --- a/internal/download/handler.go +++ b/internal/download/handler.go @@ -1,13 +1,19 @@ package download -import "github.com/gofiber/fiber/v2" +import ( + "os" + "strings" + + "github.com/gofiber/fiber/v2" +) type Handler struct { - svc *Service + svc *Service + baseURL string } -func NewHandler(svc *Service) *Handler { - return &Handler{svc: svc} +func NewHandler(svc *Service, baseURL string) *Handler { + return &Handler{svc: svc, baseURL: baseURL} } func (h *Handler) GetInfo(c *fiber.Ctx) error { @@ -18,20 +24,26 @@ func (h *Handler) GetInfo(c *fiber.Ctx) error { return c.JSON(info) } -func (h *Handler) Upsert(c *fiber.Ctx) error { - var body struct { - URL string `json:"url"` - Version string `json:"version"` - FileName string `json:"fileName"` - FileSize string `json:"fileSize"` - FileHash string `json:"fileHash"` +// Upload accepts a raw binary body (application/octet-stream). +// The filename is passed as a query parameter: ?filename=A301_v1.0.zip +func (h *Handler) Upload(c *fiber.Ctx) error { + filename := strings.TrimSpace(c.Query("filename", "game.zip")) + if !strings.HasSuffix(strings.ToLower(filename), ".zip") { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zip 파일만 업로드 가능합니다"}) } - if err := c.BodyParser(&body); err != nil || body.URL == "" || body.Version == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "url과 version은 필수입니다"}) - } - info, err := h.svc.Upsert(body.URL, body.Version, body.FileName, body.FileSize, body.FileHash) + + body := c.Request().BodyStream() + info, err := h.svc.Upload(filename, body, h.baseURL) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "업데이트에 실패했습니다"}) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "업로드 실패: " + err.Error()}) } return c.JSON(info) } + +func (h *Handler) ServeFile(c *fiber.Ctx) error { + path := h.svc.GameFilePath() + if _, err := os.Stat(path); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "파일이 없습니다"}) + } + return c.SendFile(path) +} diff --git a/internal/download/service.go b/internal/download/service.go index 42ef60d..f0311ea 100644 --- a/internal/download/service.go +++ b/internal/download/service.go @@ -1,26 +1,109 @@ package download +import ( + "archive/zip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" +) + +var versionRe = regexp.MustCompile(`v\d+[\.\d]*`) + type Service struct { - repo *Repository + repo *Repository + gameDir string } -func NewService(repo *Repository) *Service { - return &Service{repo: repo} +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) Upsert(url, version, fileName, fileSize, fileHash string) (*Info, error) { +func (s *Service) GameFilePath() string { + return filepath.Join(s.gameDir, "game.zip") +} + +// 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) { + 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) + f.Close() + if err != nil { + os.Remove(tmpPath) + return nil, fmt.Errorf("파일 저장 실패: %w", err) + } + + if err := os.Rename(tmpPath, finalPath); err != nil { + os.Remove(tmpPath) + 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) + info, err := s.repo.GetLatest() if err != nil { info = &Info{} } - info.URL = url + info.URL = baseURL + "/api/download/file" info.Version = version - info.FileName = fileName + info.FileName = filename info.FileSize = fileSize info.FileHash = fileHash return info, s.repo.Save(info) } + +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() + io.Copy(h, rc) + rc.Close() + return hex.EncodeToString(h.Sum(nil)) + } + } + return "" +} diff --git a/main.go b/main.go index b82efb2..c200b42 100644 --- a/main.go +++ b/main.go @@ -47,10 +47,12 @@ func main() { annHandler := announcement.NewHandler(annSvc) dlRepo := download.NewRepository(database.DB) - dlSvc := download.NewService(dlRepo) - dlHandler := download.NewHandler(dlSvc) + dlSvc := download.NewService(dlRepo, config.C.GameDir) + dlHandler := download.NewHandler(dlSvc, config.C.BaseURL) - app := fiber.New() + app := fiber.New(fiber.Config{ + StreamRequestBody: true, + }) app.Use(logger.New()) app.Use(cors.New(cors.Config{ AllowOrigins: "https://a301.tolelom.xyz", diff --git a/pkg/config/config.go b/pkg/config/config.go index 0afb8eb..fa93d28 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -20,6 +20,8 @@ type Config struct { JWTExpiryHours int AdminUsername string AdminPassword string + BaseURL string + GameDir string } var C Config @@ -41,6 +43,8 @@ func Load() { JWTExpiryHours: hours, AdminUsername: getEnv("ADMIN_USERNAME", "admin"), AdminPassword: getEnv("ADMIN_PASSWORD", "admin1234"), + BaseURL: getEnv("BASE_URL", "http://localhost:8080"), + GameDir: getEnv("GAME_DIR", "/data/game"), } } diff --git a/routes/routes.go b/routes/routes.go index 32ede24..549475e 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -38,5 +38,6 @@ func Register( // Download dl := api.Group("/download") dl.Get("/info", dlH.GetInfo) - dl.Put("/info", middleware.Auth, middleware.AdminOnly, dlH.Upsert) + dl.Get("/file", dlH.ServeFile) + dl.Post("/upload", middleware.Auth, middleware.AdminOnly, dlH.Upload) }