feat: implement Modrinth modpack importer (.mrpack)

This commit is contained in:
2026-01-04 14:47:24 +03:00
parent 275c1f2d50
commit 192ec80010
3 changed files with 187 additions and 0 deletions

View File

@@ -46,6 +46,8 @@ func (h *ModpackHandler) ImportModpack(w http.ResponseWriter, r *http.Request) {
return
}
imp = importer.NewCurseForgeImporter(storagePath, apiKey)
case "modrinth":
imp = importer.NewModrinthImporter(storagePath)
default:
http.Error(w, "Invalid importer type", http.StatusBadRequest)
return

View File

@@ -0,0 +1,161 @@
package importer
import (
"archive/zip"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
)
// ModrinthImporter реализует импорт для сборок Modrinth (.mrpack).
type ModrinthImporter struct {
StoragePath string
HTTPClient *http.Client
}
// NewModrinthImporter создает новый экземпляр импортера.
func NewModrinthImporter(storagePath string) *ModrinthImporter {
return &ModrinthImporter{
StoragePath: storagePath,
HTTPClient: &http.Client{Timeout: 10 * time.Minute},
}
}
// downloadAndProcessFile скачивает файл и обрабатывает его.
func (i *ModrinthImporter) 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)
}
baseImporter := SimpleZipImporter{StoragePath: i.StoragePath}
return baseImporter.processFile(resp.Body)
}
// Import реализует основной метод интерфейса ModpackImporter.
func (i *ModrinthImporter) Import(zipPath string) ([]models.ModpackFile, error) {
r, err := zip.OpenReader(zipPath)
if err != nil {
return nil, err
}
defer r.Close()
// 1. Ищем и парсим modrinth.index.json
var indexFile *zip.File
for _, f := range r.File {
if f.Name == "modrinth.index.json" {
indexFile = f
break
}
}
if indexFile == nil {
return nil, fmt.Errorf("modrinth.index.json not found in archive")
}
idxReader, err := indexFile.Open()
if err != nil {
return nil, err
}
defer idxReader.Close()
var index ModrinthIndex
if err := json.NewDecoder(idxReader).Decode(&index); err != nil {
return nil, fmt.Errorf("failed to parse modrinth.index.json: %w", err)
}
var files []models.ModpackFile
// 2. Обрабатываем файлы из индекса (скачиваем их)
for _, modFile := range index.Files {
// Пропускаем серверные файлы, которые не нужны клиенту?
// В ТЗ сказано про "клиент", но обычно лаунчер качает всё, что нужно клиенту.
// env.client != "unsupported" (обычно "required" или "optional")
if modFile.Env.Client == "unsupported" {
continue
}
if len(modFile.Downloads) == 0 {
log.Printf("Modrinth Importer: WARN - No download URLs for file '%s', skipping.", modFile.Path)
continue
}
// Используем первый доступный URL
downloadURL := modFile.Downloads[0]
log.Printf("Modrinth Importer: Downloading '%s' from %s", modFile.Path, downloadURL)
// Скачиваем файл и пересчитываем его хеш/размер (и сохраняем локально, если нужно)
// Важно: Modrinth дает хеши в индексе. Мы могли бы использовать их, но наша система
// построена на том, что мы храним файлы у себя. Поэтому нам всё равно нужно скачать их
// и сохранить в наше хранилище. Метод downloadAndProcessFile делает именно это.
hash, size, err := i.downloadAndProcessFile(downloadURL)
if err != nil {
log.Printf("Modrinth Importer: WARN - Failed to download/process '%s': %v", modFile.Path, err)
continue
}
// Сверка хеша для надежности (опционально, но полезно)
if modFile.Hashes.SHA1 != "" && modFile.Hashes.SHA1 != hash {
log.Printf("Modrinth Importer: WARN - Hash mismatch for '%s'. Index SHA1: %s, Calculated: %s", modFile.Path, modFile.Hashes.SHA1, hash)
// Можно решить: падать с ошибкой или доверять тому, что скачали.
// Пока просто предупреждаем.
}
files = append(files, models.ModpackFile{
RelativePath: modFile.Path,
FileHash: hash,
FileSize: size,
DownloadURL: downloadURL,
})
}
// 3. Обрабатываем overrides
baseImporter := SimpleZipImporter{StoragePath: i.StoragePath}
for _, f := range r.File {
// В .mrpack файлы для копирования лежат в папке "overrides/" (как правило)
// Спецификация говорит, что папка может называться иначе? Нет, обычно overrides.
// Но лучше проверить все папки, кроме системных.
// Спецификация Modrinth: "Files that are included in the modpack archive are located in the overrides directory."
prefix := "overrides/"
if strings.HasPrefix(f.Name, prefix) && !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, prefix)
files = append(files, models.ModpackFile{
RelativePath: relativePath,
FileHash: hash,
FileSize: size,
})
}
}
return files, nil
}

View File

@@ -0,0 +1,24 @@
package importer
// ModrinthIndex - структура modrinth.index.json
type ModrinthIndex struct {
FormatVersion int `json:"formatVersion"`
Game string `json:"game"`
VersionID string `json:"versionId"`
Name string `json:"name"`
Summary string `json:"summary"`
Files []struct {
Path string `json:"path"`
Hashes struct {
SHA1 string `json:"sha1"`
SHA512 string `json:"sha512"`
} `json:"hashes"`
Env struct {
Client string `json:"client"`
Server string `json:"server"`
} `json:"env"`
Downloads []string `json:"downloads"`
FileSize int64 `json:"fileSize"` // В спецификации это поле может быть uint64, но int64 удобнее
} `json:"files"`
Dependencies map[string]string `json:"dependencies"`
}