feat(modpack): added curseforge importer

This commit is contained in:
2025-06-19 16:45:53 +03:00
parent ac9db69585
commit 22d54dea63
3 changed files with 233 additions and 12 deletions

View File

@@ -20,18 +20,17 @@ type ModpackHandler struct {
// ImportModpack обрабатывает загрузку и импорт модпака.
func (h *ModpackHandler) ImportModpack(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(512 << 20); err != nil { // 512 MB лимит
if err := r.ParseMultipartForm(512 << 20); err != nil {
http.Error(w, "File too large", http.StatusBadRequest)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "Invalid file upload", http.StatusBadRequest)
return
}
defer file.Close()
// Получаем тип импортера и метод из формы
importerType := r.FormValue("importerType")
importMethod := r.FormValue("importMethod")
// sourceURL := r.FormValue("sourceUrl")
// --- Получаем zip-файл ---
tempFile, err := os.CreateTemp("", "modpack-*.zip")
if err != nil {
http.Error(w, "Could not create temp file", http.StatusInternalServerError)
@@ -40,14 +39,43 @@ func (h *ModpackHandler) ImportModpack(w http.ResponseWriter, r *http.Request) {
defer os.Remove(tempFile.Name())
defer tempFile.Close()
_, err = io.Copy(tempFile, file)
if err != nil {
http.Error(w, "Could not save temp file", http.StatusInternalServerError)
if importMethod == "file" {
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "Invalid file upload", http.StatusBadRequest)
return
}
defer file.Close()
if _, err := io.Copy(tempFile, file); err != nil {
http.Error(w, "Could not save temp file", http.StatusInternalServerError)
return
}
} else if importMethod == "url" {
http.Error(w, "Import by URL is not implemented yet", http.StatusNotImplemented)
return
} else {
http.Error(w, "Invalid import method", http.StatusBadRequest)
return
}
// --- Выбираем и запускаем импортер ---
var imp importer.ModpackImporter
storagePath := os.Getenv("MODPACKS_STORAGE_PATH")
imp := &importer.SimpleZipImporter{StoragePath: storagePath}
switch importerType {
case "simple":
imp = &importer.SimpleZipImporter{StoragePath: storagePath}
case "curseforge":
apiKey := os.Getenv("CURSEFORGE_API_KEY")
if apiKey == "" {
http.Error(w, "CurseForge API key is not configured on the server", http.StatusInternalServerError)
return
}
imp = importer.NewCurseForgeImporter(storagePath, apiKey)
default:
http.Error(w, "Invalid importer type", http.StatusBadRequest)
return
}
files, err := imp.Import(tempFile.Name())
if err != nil {
@@ -55,6 +83,7 @@ func (h *ModpackHandler) ImportModpack(w http.ResponseWriter, r *http.Request) {
return
}
// --- Сохраняем результат в БД ---
modpack := &models.Modpack{
Name: r.FormValue("name"),
DisplayName: r.FormValue("displayName"),
@@ -70,6 +99,5 @@ func (h *ModpackHandler) ImportModpack(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, "Modpack '%s' imported successfully with %d files.", modpack.DisplayName, len(files))
// Запускаем очистку в фоне, чтобы не блокировать ответ
go h.JanitorService.CleanOrphanedFiles(context.Background())
}

View File

@@ -0,0 +1,165 @@
package importer
import (
"archive/zip"
"context"
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"strings"
"time"
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
)
// CurseForgeImporter реализует импорт для сборок CurseForge.
type CurseForgeImporter struct {
StoragePath string
APIKey string
HTTPClient *http.Client
}
// NewCurseForgeImporter создает новый экземпляр импортера.
func NewCurseForgeImporter(storagePath, apiKey string) *CurseForgeImporter {
return &CurseForgeImporter{
StoragePath: storagePath,
APIKey: apiKey,
HTTPClient: &http.Client{Timeout: 60 * time.Second},
}
}
// downloadAndProcessFile скачивает файл по URL и передает его в общий обработчик.
func (i *CurseForgeImporter) downloadAndProcessFile(url string) (hash string, size int64, err error) {
req, err := http.NewRequestWithContext(context.Background(), "GET", url, nil)
if err != nil {
return "", 0, err
}
resp, err := i.HTTPClient.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", 0, fmt.Errorf("bad status downloading file: %s", resp.Status)
}
// Используем тот же processFile, что и в SimpleZipImporter
baseImporter := SimpleZipImporter{StoragePath: i.StoragePath}
return baseImporter.processFile(resp.Body)
}
// getFileDownloadURL получает прямую ссылку на скачивание файла с API CurseForge.
func (i *CurseForgeImporter) getFileDownloadURL(projectID, fileID int) (string, error) {
url := fmt.Sprintf("https://api.curseforge.com/v1/mods/%d/files/%d/download-url", projectID, fileID)
req, err := http.NewRequestWithContext(context.Background(), "GET", url, nil)
if err != nil {
return "", err
}
req.Header.Set("x-api-key", i.APIKey)
resp, err := i.HTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("bad status getting download URL: %s", resp.Status)
}
var apiResp CurseForgeFileResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return "", err
}
return apiResp.Data.DownloadURL, nil
}
// Import реализует основной метод для CurseForge.
func (i *CurseForgeImporter) Import(zipPath string) ([]models.ModpackFile, error) {
r, err := zip.OpenReader(zipPath)
if err != nil {
return nil, err
}
defer r.Close()
var manifestFile *zip.File
for _, f := range r.File {
if f.Name == "manifest.json" {
manifestFile = f
break
}
}
if manifestFile == nil {
return nil, fmt.Errorf("manifest.json not found in archive")
}
mfReader, err := manifestFile.Open()
if err != nil {
return nil, err
}
defer mfReader.Close()
var manifest CurseForgeManifest
if err := json.NewDecoder(mfReader).Decode(&manifest); err != nil {
return nil, fmt.Errorf("failed to parse manifest.json: %w", err)
}
var files []models.ModpackFile
// 1. Обрабатываем файлы из манифеста (моды)
for _, modFile := range manifest.Files {
downloadURL, err := i.getFileDownloadURL(modFile.ProjectID, modFile.FileID)
if err != nil {
return nil, fmt.Errorf("failed to get download url for fileID %d: %w", modFile.FileID, err)
}
hash, size, err := i.downloadAndProcessFile(downloadURL)
if err != nil {
return nil, fmt.Errorf("failed to process downloaded file for fileID %d: %w", modFile.FileID, err)
}
// Путь для модов обычно "mods/filename.jar", но у нас нет имени файла.
// Лаунчеру это не важно, он будет сохранять по хешу.
// Для манифеста мы можем сгенерировать путь.
relativePath := filepath.Join("mods", fmt.Sprintf("%d-%d.jar", modFile.ProjectID, modFile.FileID))
files = append(files, models.ModpackFile{
RelativePath: relativePath,
FileHash: hash,
FileSize: size,
DownloadURL: downloadURL,
})
}
// 2. Обрабатываем файлы из папки overrides
baseImporter := SimpleZipImporter{StoragePath: i.StoragePath}
for _, f := range r.File {
if strings.HasPrefix(f.Name, manifest.Overrides+"/") && !f.FileInfo().IsDir() {
fileReader, err := f.Open()
if err != nil {
return nil, err
}
hash, size, err := baseImporter.processFile(fileReader)
if err != nil {
fileReader.Close()
return nil, err
}
fileReader.Close()
relativePath := strings.TrimPrefix(f.Name, manifest.Overrides+"/")
files = append(files, models.ModpackFile{
RelativePath: relativePath,
FileHash: hash,
FileSize: size,
})
}
}
return files, nil
}

View File

@@ -0,0 +1,28 @@
package importer
type CurseForgeManifest struct {
Minecraft struct {
Version string `json:"version"`
ModLoaders []struct {
ID string `json:"id"`
Primary bool `json:"primary"`
} `json:"modLoaders"`
} `json:"minecraft"`
ManifestType string `json:"manifestType"`
ManifestVersion int `json:"manifestVersion"`
Name string `json:"name"`
Version string `json:"version"`
Author string `json:"author"`
Files []struct {
ProjectID int `json:"projectID"`
FileID int `json:"fileID"`
Required bool `json:"required"`
} `json:"files"`
Overrides string `json:"overrides"`
}
type CurseForgeFileResponse struct {
Data struct {
DownloadURL string `json:"downloadUrl"`
} `json:"data"`
}