diff --git a/internal/admin/admin.go b/internal/admin/admin.go index d74a7e9..4adb00d 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -134,33 +134,101 @@ func (h *Handler) listModpacks(w http.ResponseWriter, r *http.Request) { } func (h *Handler) createModpack(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - utils.WriteError(w, http.StatusBadRequest, "Cannot read body") + // Parse multipart form (for both modpack data and file uploads) + if err := r.ParseMultipartForm(500 << 20); err != nil { // 500 MB max + utils.WriteError(w, http.StatusBadRequest, "Cannot parse form (max 500 MB)") return } - var req modpackRequest - if err := json.Unmarshal(body, &req); err != nil { - utils.WriteError(w, http.StatusBadRequest, "Invalid JSON") - return - } + // Extract modpack data from form + slug := r.FormValue("slug") + name := r.FormValue("name") + minecraftVersion := r.FormValue("minecraft_version") + javaVersionStr := r.FormValue("java_version") + serverIP := r.FormValue("server_ip") - if req.Slug == "" || req.Name == "" || req.MinecraftVersion == "" { + // Validate required fields + if slug == "" || name == "" || minecraftVersion == "" { utils.WriteError(w, http.StatusBadRequest, "slug, name, and minecraft_version are required") return } - _, err = h.db.Pool().Exec(r.Context(), + // Parse java_version + javaVersion := 8 // default + if javaVersionStr != "" { + if parsed, err := strconv.Atoi(javaVersionStr); err == nil { + javaVersion = parsed + } + } + + // Insert modpack into database + _, err := h.db.Pool().Exec(r.Context(), `INSERT INTO modpacks (slug, name, minecraft_version, java_version, server_ip, is_active) VALUES ($1, $2, $3, $4, $5, true)`, - req.Slug, req.Name, req.MinecraftVersion, req.JavaVersion, req.ServerIP, + slug, name, minecraftVersion, javaVersion, serverIP, ) if err != nil { utils.WriteError(w, http.StatusConflict, "Modpack with this slug already exists") return } + // Handle file uploads if any + files := r.MultipartForm.File["files"] + if len(files) > 0 { + // Create instance directory for this modpack + uploadDir := filepath.Join(h.cfg.CASDir, "..", "instances", slug) + os.MkdirAll(uploadDir, 0o755) + + var uploaded []string + for _, fh := range files { + src, err := fh.Open() + if err != nil { + continue + } + + data, err := io.ReadAll(src) + src.Close() + if err != nil { + continue + } + + if strings.HasSuffix(strings.ToLower(fh.Filename), ".zip") { + extracted, err := h.extractZip(data, uploadDir) + if err == nil { + uploaded = append(uploaded, extracted...) + continue + } + } + + hash := utils.SHA1Bytes(data) + destDir := filepath.Join(h.cfg.CASDir, hash[:2]) + os.MkdirAll(destDir, 0o755) + os.WriteFile(filepath.Join(destDir, hash), data, 0o644) + + h.db.Pool().Exec(r.Context(), + `INSERT INTO global_files (sha1, size_bytes, file_name) VALUES ($1, $2, $3) + ON CONFLICT (sha1) DO NOTHING`, + hash, int64(len(data)), fh.Filename, + ) + + instPath := filepath.Join(uploadDir, "mods", fh.Filename) + os.MkdirAll(filepath.Dir(instPath), 0o755) + os.WriteFile(instPath, data, 0o644) + + uploaded = append(uploaded, fh.Filename) + } + + // TODO: Optionally generate manifest after upload + // For now, just return success with upload info + utils.WriteJSON(w, http.StatusCreated, map[string]interface{}{ + "status": "created", + "uploaded": uploaded, + "count": len(uploaded), + }) + return + } + + // No files uploaded, just created modpack utils.WriteJSON(w, http.StatusCreated, map[string]string{"status": "created"}) } @@ -171,28 +239,101 @@ func (h *Handler) updateModpack(w http.ResponseWriter, r *http.Request) { return } - body, err := io.ReadAll(r.Body) - if err != nil { - utils.WriteError(w, http.StatusBadRequest, "Cannot read body") + // Parse multipart form (for both modpack data and file uploads) + if err := r.ParseMultipartForm(500 << 20); err != nil { // 500 MB max + utils.WriteError(w, http.StatusBadRequest, "Cannot parse form (max 500 MB)") return } - var req modpackRequest - if err := json.Unmarshal(body, &req); err != nil { - utils.WriteError(w, http.StatusBadRequest, "Invalid JSON") + // Extract modpack data from form + name := r.FormValue("name") + slug := r.FormValue("slug") + minecraftVersion := r.FormValue("minecraft_version") + javaVersionStr := r.FormValue("java_version") + serverIP := r.FormValue("server_ip") + + // Validate required fields + if name == "" || slug == "" || minecraftVersion == "" { + utils.WriteError(w, http.StatusBadRequest, "name, slug, and minecraft_version are required") return } + // Parse java_version + javaVersion := 8 // default + if javaVersionStr != "" { + if parsed, err := strconv.Atoi(javaVersionStr); err == nil { + javaVersion = parsed + } + } + + // Update modpack in database _, err = h.db.Pool().Exec(r.Context(), `UPDATE modpacks SET name=$1, slug=$2, minecraft_version=$3, java_version=$4, server_ip=$5 WHERE id=$6`, - req.Name, req.Slug, req.MinecraftVersion, req.JavaVersion, req.ServerIP, id, + name, slug, minecraftVersion, javaVersion, serverIP, id, ) if err != nil { utils.WriteError(w, http.StatusInternalServerError, "Update failed") return } + // Handle file uploads if any + files := r.MultipartForm.File["files"] + if len(files) > 0 { + // Create instance directory for this modpack + uploadDir := filepath.Join(h.cfg.CASDir, "..", "instances", slug) + os.MkdirAll(uploadDir, 0o755) + + var uploaded []string + for _, fh := range files { + src, err := fh.Open() + if err != nil { + continue + } + + data, err := io.ReadAll(src) + src.Close() + if err != nil { + continue + } + + if strings.HasSuffix(strings.ToLower(fh.Filename), ".zip") { + extracted, err := h.extractZip(data, uploadDir) + if err == nil { + uploaded = append(uploaded, extracted...) + continue + } + } + + hash := utils.SHA1Bytes(data) + destDir := filepath.Join(h.cfg.CASDir, hash[:2]) + os.MkdirAll(destDir, 0o755) + os.WriteFile(filepath.Join(destDir, hash), data, 0o644) + + h.db.Pool().Exec(r.Context(), + `INSERT INTO global_files (sha1, size_bytes, file_name) VALUES ($1, $2, $3) + ON CONFLICT (sha1) DO NOTHING`, + hash, int64(len(data)), fh.Filename, + ) + + instPath := filepath.Join(uploadDir, "mods", fh.Filename) + os.MkdirAll(filepath.Dir(instPath), 0o755) + os.WriteFile(instPath, data, 0o644) + + uploaded = append(uploaded, fh.Filename) + } + + // TODO: Optionally generate manifest after upload + // For now, just return success with upload info + utils.WriteJSON(w, http.StatusOK, map[string]interface{}{ + "status": "updated", + "uploaded": uploaded, + "count": len(uploaded), + }) + return + } + + // No files uploaded, just updated modpack utils.WriteJSON(w, http.StatusOK, map[string]string{"status": "updated"}) } diff --git a/internal/templates/html/admin.html b/internal/templates/html/admin.html index d4f1b76..f2a78bf 100644 --- a/internal/templates/html/admin.html +++ b/internal/templates/html/admin.html @@ -29,56 +29,46 @@ - +
-

Загрузка нового модпака

-
+

Создание и загрузка модпака

+
+
+ + +
-
- - -
- +
- +
- +
- -
-
- -

Загрузка файлов для модпака

-
-
- - - -
-
- +
+ -

Поддерживаются архивы .zip ( будут распакованы) и отдельные файлы.

+

Поддерживаются архивы .zip ( будут распакованы автоматически) и отдельные файлы.

+ - + + -
@@ -105,6 +95,25 @@ function getToken() { return localStorage.getItem('token'); } +// Generate slug from name (transliterate and clean) +function generateSlugFromName(name) { + if (!name) return ''; + + // Convert to lowercase and trim + let slug = name.toLowerCase().trim(); + + // Replace non-alphanumeric characters with hyphens + slug = slug.replace(/[^a-z0-9]+/g, '-'); + + // Remove leading/trailing hyphens + slug = slug.replace(/^-+|-+$/g, ''); + + // Limit length + slug = slug.substring(0, 32); + + return slug || 'modpack'; +} + // Fetch and display modpacks async function loadModpacks() { const token = getToken(); @@ -126,11 +135,9 @@ async function loadModpacks() { const modpacks = await response.json(); const tbody = document.getElementById('modpacks-tbody'); - const datalist = document.getElementById('modpack-slugs'); // Clear existing tbody.innerHTML = ''; - datalist.innerHTML = ''; modpacks.forEach(mp => { const tr = document.createElement('tr'); @@ -147,11 +154,6 @@ async function loadModpacks() { `; tbody.appendChild(tr); - - // Add to datalist for upload form - const option = document.createElement('option'); - option.value = mp.slug; - datalist.appendChild(option); }); } catch (err) { showAlert('Ошибка загрузки модпаков: ' + err.message, null); @@ -159,56 +161,40 @@ async function loadModpacks() { } } -// Handle modpack creation +// Handle unified form submission (create modpack + upload files) document.getElementById('modpack-form').addEventListener('submit', async (e) => { e.preventDefault(); const token = getToken(); if (!token) return; - const formData = new FormData(e.target); - const data = { - slug: formData.get('slug'), - name: formData.get('name'), - minecraft_version: formData.get('minecraft_version'), - java_version: parseInt(formData.get('java_version')), - server_ip: formData.get('server_ip') - }; + const form = e.target; + const formData = new FormData(form); - try { - const response = await fetch('/api/admin/modpacks', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + token - }, - body: JSON.stringify(data) - }); - - if (response.ok) { - showAlert(null, 'Модпак успешно создан'); - e.target.reset(); - loadModpacks(); // Refresh list - } else { - const error = await response.json(); - showAlert(error.errorMessage || 'Ошибка создания модпака', null); - } - } catch (err) { - showAlert('Ошибка сети: ' + err.message, null); - } -}); - -// Handle file upload -document.getElementById('upload-form').addEventListener('submit', async (e) => { - e.preventDefault(); - const token = getToken(); - if (!token) return; - - const formData = new FormData(e.target); - const slug = formData.get('slug'); + // Get values + const name = formData.get('name'); + let slug = formData.get('slug'); + const minecraftVersion = formData.get('minecraft_version'); + const javaVersionStr = formData.get('java_version'); + const serverIP = formData.get('server_ip'); const files = document.getElementById('files').files; - if (files.length === 0) { - showAlert('Пожалуйста, выберите файлы для загрузки', null); + // Auto-generate slug from name if slug field is empty or matches name-based prediction + const predictedSlug = generateSlugFromName(name); + if (!slug || slug === predictedSlug) { + slug = predictedSlug; + document.getElementById('slug').value = slug; + } + + // Validate required fields + if (!slug || !name || !minecraftVersion) { + showAlert('Slug, название и версия Minecraft обязательны', null); + return; + } + + // Parse java_version + const javaVersion = javaVersionStr ? parseInt(javaVersionStr) : 8; + if (isNaN(javaVersion)) { + showAlert('Неверная версия Java', null); return; } @@ -221,8 +207,8 @@ document.getElementById('upload-form').addEventListener('submit', async (e) => { progressBar.value = 0; try { - // We'll use the existing upload endpoint: POST /api/admin/modpacks/{slug}/upload - const response = await fetch(`/api/admin/modpacks/${slug}/upload`, { + // Send request to create modpack with files + const response = await fetch('/api/admin/modpacks', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token @@ -232,12 +218,19 @@ document.getElementById('upload-form').addEventListener('submit', async (e) => { if (response.ok) { const result = await response.json(); - showAlert(null, `Загружено файлов: ${result.count}`); - document.getElementById('upload-form').reset(); - progressDiv.style.display = 'none'; + let message = 'Модпак успешно создан'; + if result.uploaded && result.uploaded.length > 0 { + message += ` и загружено ${result.uploaded.length} файлов`; + } else if result.count && result.count > 0 { + message += ` и загружено ${result.count} файлов`; + } + showAlert(null, message); + form.reset(); + document.getElementById('slug').value = ''; // Clear slug for next time + loadModpacks(); // Refresh list } else { const error = await response.json(); - showAlert(error.errorMessage || 'Ошибка загрузки файлов', null); + showAlert(error.errorMessage || 'Ошибка создания модпака', null); } } catch (err) { showAlert('Ошибка сети: ' + err.message, null); @@ -246,15 +239,22 @@ document.getElementById('upload-form').addEventListener('submit', async (e) => { } }); -// Simulate upload progress (since we don't have progress events from fetch with FormData easily) -// For a real progress bar, we'd need to use XMLHttpSocket or a custom backend endpoint. -// For now, we'll just show indeterminate or fake progress. -// We'll update this if we decide to implement proper progress later. +// Handle file input change for auto-generating slug +document.getElementById('name').addEventListener('input', function(e) { + const name = e.target.value; + const predictedSlug = generateSlugFromName(name); -// Placeholder for edit function (to be implemented if we add edit endpoint) -function loadModpackForEdit(slug) { - alert('Редактирование пока не реализовано. Используйте API напрямую.'); -} + // Only auto-update slug if user hasn't manually edited it + const slugInput = document.getElementById('slug'); + if (!slugInput.dataset.manualEdit) { + slugInput.value = predictedSlug; + } +}); + +// Mark slug as manually edited when user changes it +document.getElementById('slug').addEventListener('input', function(e) { + e.target.dataset.manualEdit = 'true'; +}); // Initialize document.addEventListener('DOMContentLoaded', () => {