diff --git a/internal/api/modpack_handler.go b/internal/api/modpack_handler.go index 2b30393..108d415 100644 --- a/internal/api/modpack_handler.go +++ b/internal/api/modpack_handler.go @@ -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()) } diff --git a/internal/core/importer/curseforge.go b/internal/core/importer/curseforge.go new file mode 100644 index 0000000..e84b8fc --- /dev/null +++ b/internal/core/importer/curseforge.go @@ -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 +} diff --git a/internal/core/importer/curseforge_types.go b/internal/core/importer/curseforge_types.go new file mode 100644 index 0000000..924fdd9 --- /dev/null +++ b/internal/core/importer/curseforge_types.go @@ -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"` +}