Compare commits
2 Commits
ac9db69585
...
29e85570f8
| Author | SHA1 | Date | |
|---|---|---|---|
| 29e85570f8 | |||
| 22d54dea63 |
@@ -20,41 +20,86 @@ type ModpackHandler struct {
|
|||||||
|
|
||||||
// ImportModpack обрабатывает загрузку и импорт модпака.
|
// ImportModpack обрабатывает загрузку и импорт модпака.
|
||||||
func (h *ModpackHandler) ImportModpack(w http.ResponseWriter, r *http.Request) {
|
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)
|
http.Error(w, "File too large", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
file, _, err := r.FormFile("file")
|
importerType := r.FormValue("importerType")
|
||||||
if err != nil {
|
importMethod := r.FormValue("importMethod")
|
||||||
http.Error(w, "Invalid file upload", http.StatusBadRequest)
|
sourceURL := r.FormValue("sourceUrl")
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
tempFile, err := os.CreateTemp("", "modpack-*.zip")
|
var tempZipPath string
|
||||||
if err != nil {
|
var err error
|
||||||
http.Error(w, "Could not create temp file", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// --- Выбираем импортер ---
|
||||||
|
var imp importer.ModpackImporter
|
||||||
storagePath := os.Getenv("MODPACKS_STORAGE_PATH")
|
storagePath := os.Getenv("MODPACKS_STORAGE_PATH")
|
||||||
imp := &importer.SimpleZipImporter{StoragePath: storagePath}
|
|
||||||
|
|
||||||
files, err := imp.Import(tempFile.Name())
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Получаем zip-файл ---
|
||||||
|
if importMethod == "file" {
|
||||||
|
file, _, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid file upload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
tempFile, err := os.CreateTemp("", "modpack-*.zip")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Could not create temp file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tempFile.Close()
|
||||||
|
defer os.Remove(tempFile.Name())
|
||||||
|
|
||||||
|
if _, err := io.Copy(tempFile, file); err != nil {
|
||||||
|
http.Error(w, "Could not save temp file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tempZipPath = tempFile.Name()
|
||||||
|
|
||||||
|
} else if importMethod == "url" {
|
||||||
|
cfImporter, ok := imp.(*importer.CurseForgeImporter)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Importer type does not support URL import", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tempZipPath, err = cfImporter.DownloadModpackFromURL(sourceURL)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to download from URL: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.Remove(tempZipPath)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
http.Error(w, "Invalid import method", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Запускаем импорт ---
|
||||||
|
files, err := imp.Import(tempZipPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Import failed: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Import failed: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Сохраняем результат в БД ---
|
||||||
modpack := &models.Modpack{
|
modpack := &models.Modpack{
|
||||||
Name: r.FormValue("name"),
|
Name: r.FormValue("name"),
|
||||||
DisplayName: r.FormValue("displayName"),
|
DisplayName: r.FormValue("displayName"),
|
||||||
@@ -70,6 +115,5 @@ func (h *ModpackHandler) ImportModpack(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
fmt.Fprintf(w, "Modpack '%s' imported successfully with %d files.", modpack.DisplayName, len(files))
|
fmt.Fprintf(w, "Modpack '%s' imported successfully with %d files.", modpack.DisplayName, len(files))
|
||||||
|
|
||||||
// Запускаем очистку в фоне, чтобы не блокировать ответ
|
|
||||||
go h.JanitorService.CleanOrphanedFiles(context.Background())
|
go h.JanitorService.CleanOrphanedFiles(context.Background())
|
||||||
}
|
}
|
||||||
|
|||||||
276
internal/core/importer/curseforge.go
Normal file
276
internal/core/importer/curseforge.go
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
package importer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
// findModpackBySlug ищет ID проекта по его "слагу" (части URL).
|
||||||
|
func (i *CurseForgeImporter) findModpackBySlug(slug string) (int, error) {
|
||||||
|
apiURL := fmt.Sprintf("https://api.curseforge.com/v1/mods/search?gameId=432&slug=%s", url.QueryEscape(slug))
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), "GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("x-api-key", i.APIKey)
|
||||||
|
|
||||||
|
resp, err := i.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var searchResp CurseForgeSearchResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(searchResp.Data) == 0 {
|
||||||
|
return 0, fmt.Errorf("modpack with slug '%s' not found", slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchResp.Data[0].ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLatestModpackFileURL находит URL для скачивания последнего файла проекта.
|
||||||
|
func (i *CurseForgeImporter) getLatestModpackFileURL(projectID int) (string, error) {
|
||||||
|
apiURL := fmt.Sprintf("https://api.curseforge.com/v1/mods/%d/files", projectID)
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), "GET", apiURL, 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()
|
||||||
|
|
||||||
|
var filesResp CurseForgeFilesResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&filesResp); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(filesResp.Data) == 0 {
|
||||||
|
return "", fmt.Errorf("no files found for projectID %d", projectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Последний загруженный файл обычно идет первым в списке
|
||||||
|
latestFile := filesResp.Data[0]
|
||||||
|
return latestFile.DownloadURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadModpackFromURL скачивает zip-архив модпака по URL страницы CurseForge.
|
||||||
|
func (i *CurseForgeImporter) DownloadModpackFromURL(pageURL string) (string, error) {
|
||||||
|
// 1. Парсим URL, чтобы извлечь slug
|
||||||
|
parsedURL, err := url.Parse(pageURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid url: %w", err)
|
||||||
|
}
|
||||||
|
slug := path.Base(parsedURL.Path)
|
||||||
|
log.Printf("Importer: Extracted slug '%s' from URL", slug)
|
||||||
|
|
||||||
|
// 2. Находим ID проекта по slug
|
||||||
|
projectID, err := i.findModpackBySlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
log.Printf("Importer: Found projectID %d for slug '%s'", projectID, slug)
|
||||||
|
|
||||||
|
// 3. Получаем URL для скачивания последнего файла
|
||||||
|
downloadURL, err := i.getLatestModpackFileURL(projectID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
log.Printf("Importer: Found download URL: %s", downloadURL)
|
||||||
|
|
||||||
|
// 4. Скачиваем zip-архив во временный файл
|
||||||
|
tempFile, err := os.CreateTemp("", "modpack-*.zip")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer tempFile.Close()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), "GET", downloadURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := i.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(tempFile, resp.Body); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Importer: Successfully downloaded modpack to %s", tempFile.Name())
|
||||||
|
return tempFile.Name(), nil
|
||||||
|
}
|
||||||
48
internal/core/importer/curseforge_types.go
Normal file
48
internal/core/importer/curseforge_types.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurseForgeSearchResponse - ответ от эндпоинта поиска
|
||||||
|
type CurseForgeSearchResponse struct {
|
||||||
|
Data []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurseForgeFilesResponse - ответ от эндпоинта получения файлов проекта
|
||||||
|
type CurseForgeFilesResponse struct {
|
||||||
|
Data []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
DownloadURL string `json:"downloadUrl"`
|
||||||
|
} `json:"data"`
|
||||||
|
Pagination struct {
|
||||||
|
TotalCount int `json:"totalCount"`
|
||||||
|
} `json:"pagination"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CurseForgeFileResponse struct {
|
||||||
|
Data struct {
|
||||||
|
DownloadURL string `json:"downloadUrl"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user