Files
backend/internal/api/modpack_handler.go

285 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"path"
"gitea.mrixs.me/minecraft-platform/backend/internal/core"
"gitea.mrixs.me/minecraft-platform/backend/internal/core/importer"
"gitea.mrixs.me/minecraft-platform/backend/internal/database"
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
"gitea.mrixs.me/minecraft-platform/backend/internal/ws"
)
type ModpackHandler struct {
ModpackRepo *database.ModpackRepository
JobRepo *database.JobRepository
JanitorService *core.FileJanitorService
Hub *ws.Hub
}
// ImportJobParams содержит параметры для фоновой задачи импорта
type ImportJobParams struct {
ImporterType string
ImportMethod string
SourceURL string
TempZipPath string
Name string
DisplayName string
MCVersion string
}
// ImportModpack обрабатывает запрос на импорт и запускает асинхронную задачу
func (h *ModpackHandler) ImportModpack(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(512 << 20); err != nil {
http.Error(w, "File too large", http.StatusBadRequest)
return
}
params := ImportJobParams{
ImporterType: r.FormValue("importerType"),
ImportMethod: r.FormValue("importMethod"),
SourceURL: r.FormValue("sourceUrl"),
Name: r.FormValue("name"),
DisplayName: r.FormValue("displayName"),
MCVersion: r.FormValue("mcVersion"),
}
// Валидация
if params.Name == "" || params.DisplayName == "" || params.MCVersion == "" {
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}
// Обработка загрузки файла
if params.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
}
// Не удаляем файл здесь, так как он нужен worker-у. Удалим в worker-е.
// defer os.Remove(tempFile.Name())
if _, err := io.Copy(tempFile, file); err != nil {
tempFile.Close()
os.Remove(tempFile.Name())
http.Error(w, "Could not save temp file", http.StatusInternalServerError)
return
}
tempFile.Close()
params.TempZipPath = tempFile.Name()
}
// Создаем задачу в БД
jobID, err := h.JobRepo.CreateJob(r.Context())
if err != nil {
if params.ImportMethod == "file" {
os.Remove(params.TempZipPath)
}
http.Error(w, fmt.Sprintf("Failed to create job: %v", err), http.StatusInternalServerError)
return
}
// Запускаем worker
go h.processImportJob(jobID, params)
// Отправляем ответ
w.WriteHeader(http.StatusAccepted)
json.NewEncoder(w).Encode(map[string]interface{}{
"job_id": jobID,
"message": "Import job started",
})
}
// processImportJob выполняет импорт в фоне
func (h *ModpackHandler) processImportJob(jobID int, params ImportJobParams) {
ctx := context.Background()
h.updateJobStatus(ctx, jobID, models.JobStatusDownloading, 10, "")
// Очистка временного файла по завершении
if params.TempZipPath != "" {
defer os.Remove(params.TempZipPath)
}
storagePath := os.Getenv("MODPACKS_STORAGE_PATH")
var imp importer.ModpackImporter
// Настройка импортера
switch params.ImporterType {
case "simple":
imp = &importer.SimpleZipImporter{StoragePath: storagePath}
case "curseforge":
apiKey := os.Getenv("CURSEFORGE_API_KEY")
if apiKey == "" {
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, "CurseForge API key missing")
return
}
imp = importer.NewCurseForgeImporter(storagePath, apiKey)
case "modrinth":
imp = importer.NewModrinthImporter(storagePath)
default:
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, "Invalid importer type")
return
}
// Загрузка по URL если нужно
if params.ImportMethod == "url" {
h.updateJobStatus(ctx, jobID, models.JobStatusDownloading, 20, "Downloading from URL...")
// Логика скачивания зависит от типа импортера, пока поддерживаем CurseForge
// TODO: Сделать интерфейс DownloadableImporter
if cfImporter, ok := imp.(*importer.CurseForgeImporter); ok {
zipPath, err := cfImporter.DownloadModpackFromURL(params.SourceURL)
if err != nil {
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, fmt.Sprintf("Download failed: %v", err))
return
}
params.TempZipPath = zipPath
defer os.Remove(zipPath) // Удаляем скачанный файл после обработки
} else {
// Для других типов пока не поддерживаем URL download внутри импортера
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, "URL import not supported for this type")
return
}
}
h.updateJobStatus(ctx, jobID, models.JobStatusProcessing, 40, "Processing modpack files...")
// Импорт файлов
files, err := imp.Import(params.TempZipPath)
if err != nil {
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, fmt.Sprintf("Import failed: %v", err))
return
}
h.updateJobStatus(ctx, jobID, models.JobStatusProcessing, 80, "Saving to database...")
// Сохранение в БД
modpack := &models.Modpack{
Name: params.Name,
DisplayName: params.DisplayName,
MinecraftVersion: params.MCVersion,
}
err = h.ModpackRepo.CreateModpackTx(ctx, modpack, files)
if err != nil {
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, fmt.Sprintf("Database save failed: %v", err))
return
}
h.updateJobStatus(ctx, jobID, models.JobStatusCompleted, 100, "Success")
// Запуск Janitor-а
go h.JanitorService.CleanOrphanedFiles(context.Background())
}
// updateJobStatus обновляет статус в БД и отправляет уведомление через WebSocket
func (h *ModpackHandler) updateJobStatus(ctx context.Context, jobID int, status models.ImportJobStatus, progress int, errMsg string) {
// Обновляем БД
if err := h.JobRepo.UpdateJobStatus(ctx, jobID, status, progress, errMsg); err != nil {
slog.Error("Failed to update job status in DB", "jobID", jobID, "error", err)
}
// Отправляем в WebSocket
update := map[string]interface{}{
"job_id": jobID,
"status": status,
"progress": progress,
"error_message": errMsg,
}
msg, _ := json.Marshal(update)
h.Hub.BroadcastMessage(msg)
}
// ModpackVersionResponse представляет версию модпака для ответа API
type ModpackVersionResponse struct {
FileID int `json:"file_id"`
DisplayName string `json:"display_name"`
FileName string `json:"file_name"`
FileDate string `json:"file_date"`
GameVersions []string `json:"game_versions"`
}
// GetModpackVersions возвращает список доступных версий для модпака по URL
func (h *ModpackHandler) GetModpackVersions(w http.ResponseWriter, r *http.Request) {
pageURL := r.URL.Query().Get("url")
if pageURL == "" {
http.Error(w, "Missing 'url' query parameter", http.StatusBadRequest)
return
}
apiKey := os.Getenv("CURSEFORGE_API_KEY")
if apiKey == "" {
http.Error(w, "CurseForge API key not configured", http.StatusInternalServerError)
return
}
cfImporter := importer.NewCurseForgeImporter("", apiKey)
// Извлекаем slug из URL
parsedURL, err := parseModpackURL(pageURL)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid URL: %v", err), http.StatusBadRequest)
return
}
// Ищем проект по slug
projectID, err := cfImporter.FindModpackBySlug(parsedURL)
if err != nil {
http.Error(w, fmt.Sprintf("Modpack not found: %v", err), http.StatusNotFound)
return
}
// Получаем список файлов (версий)
files, err := cfImporter.GetModpackFiles(projectID)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to get versions: %v", err), http.StatusInternalServerError)
return
}
// Преобразуем в ответ
var versions []ModpackVersionResponse
for _, f := range files {
versions = append(versions, ModpackVersionResponse{
FileID: f.ID,
DisplayName: f.DisplayName,
FileName: f.FileName,
FileDate: f.FileDate,
GameVersions: f.GameVersions,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(versions)
}
// parseModpackURL извлекает slug из URL страницы CurseForge
func parseModpackURL(rawURL string) (string, error) {
parsed, err := url.Parse(rawURL)
if err != nil {
return "", err
}
// URL вида https://www.curseforge.com/minecraft/modpacks/all-the-mods-9
// path.Base вернет "all-the-mods-9"
slug := path.Base(parsed.Path)
if slug == "" || slug == "." || slug == "/" {
return "", fmt.Errorf("could not extract modpack slug from URL")
}
return slug, nil
}