feat(modpack): added curseforge importer
This commit is contained in:
@@ -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())
|
||||
}
|
||||
|
||||
165
internal/core/importer/curseforge.go
Normal file
165
internal/core/importer/curseforge.go
Normal 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
|
||||
}
|
||||
28
internal/core/importer/curseforge_types.go
Normal file
28
internal/core/importer/curseforge_types.go
Normal 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user