feat: 게임 파일 직접 업로드 방식으로 전환
All checks were successful
Server CI/CD / deploy (push) Successful in 35s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 23:25:07 +09:00
parent 003eb4c1c2
commit 18c39bd4c5
7 changed files with 132 additions and 26 deletions

3
.gitignore vendored
View File

@@ -23,3 +23,6 @@ coverage.html
# OS
.DS_Store
Thumbs.db
# Game files
*.zip

View File

@@ -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

View File

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

View File

@@ -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
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 ""
}

View File

@@ -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",

View File

@@ -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"),
}
}

View File

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