Files
pzmanager/internal/api/server.go
T
mrleo1nidandClaude Opus 5 aec932f68f
Release / release (push) Successful in 30s
CI / test (push) Successful in 23s
Импорт пресетов сборок из ZIP-пакета
Крупные сборки раздают готовый комплект настроек архивом. Панель разбирает
его сама: находит серверный .ini, песочницу и точки появления, показывает,
что нашла (сколько модов, карт, параметров), и только по подтверждению
раскладывает файлы в новый или существующий профиль.

Порты, пароли и название сервера при этом можно оставить свои — они относятся
к машине, а не к сборке; у нового профиля они берутся из активного. Прежние
конфиги остаются рядом как .bak, мир не трогается, а импорт в работающий
профиль отклоняется: PZ перезаписал бы файлы своими при остановке.

Посторонние .ini из архива (настройки модов) за конфиг сервера не
принимаются: он опознаётся по знакомым параметрам.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 17:18:44 +03:00

263 lines
11 KiB
Go

// Package api отдаёт REST-эндпоинты панели и её статические файлы.
package api
import (
"encoding/json"
"errors"
"io/fs"
"log"
"net/http"
"strings"
"time"
"github.com/mrleo1nid/pzmanager/internal/auth"
"github.com/mrleo1nid/pzmanager/internal/backup"
"github.com/mrleo1nid/pzmanager/internal/config"
"github.com/mrleo1nid/pzmanager/internal/i18n"
"github.com/mrleo1nid/pzmanager/internal/job"
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
"github.com/mrleo1nid/pzmanager/internal/pzserver"
"github.com/mrleo1nid/pzmanager/internal/steamcmd"
"github.com/mrleo1nid/pzmanager/internal/updater"
)
// csrfHeader — заголовок, который обязан прислать клиент при изменяющих
// запросах. Браузер не поставит его в кросс-сайтовом простом запросе, поэтому
// он служит защитой от CSRF без отдельных токенов.
const csrfHeader = "X-PZM-Request"
// Server связывает HTTP-слой со всеми подсистемами менеджера.
type Server struct {
version string
cfg *config.Config
users *auth.Store
sup *pzserver.Supervisor
jobs *job.Manager
backups *backup.Manager
steam *steamcmd.Client
workshop *pzconfig.WorkshopClient
updater *updater.Updater
static fs.FS
// secureCookies включает флаг Secure у cookie сессии (панель за HTTPS).
secureCookies bool
// setupToken защищает первичное создание администратора: менеджер печатает
// его при старте, когда учёток ещё нет.
setupToken string
// setupTokenFile — копия кода на диске, которую показывает установщик.
// После создания администратора файл больше не нужен.
setupTokenFile string
}
// Options — то, что нужно собрать серверу API.
type Options struct {
// Version — версия сборки, панель показывает её в настройках.
Version string
Config *config.Config
Users *auth.Store
Supervisor *pzserver.Supervisor
Jobs *job.Manager
Backups *backup.Manager
Steam *steamcmd.Client
Static fs.FS
SecureCookies bool
SetupToken string
// SetupTokenFile — файл с кодом первичной настройки, удаляется после
// создания администратора.
SetupTokenFile string
// Repo — репозиторий, из которого панель берёт обновления.
Repo string
}
// New собирает HTTP-обработчик панели.
func New(o Options) *Server {
return &Server{
version: o.Version,
cfg: o.Config,
users: o.Users,
sup: o.Supervisor,
jobs: o.Jobs,
backups: o.Backups,
steam: o.Steam,
workshop: pzconfig.NewWorkshopClient(),
updater: updater.New(o.Repo, o.Version),
static: o.Static,
secureCookies: o.SecureCookies,
setupToken: o.SetupToken,
setupTokenFile: o.SetupTokenFile,
}
}
// Handler возвращает роутер со всеми маршрутами панели.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// Публичные эндпоинты: без них нельзя войти.
mux.HandleFunc("GET /api/session", s.handleSession)
mux.HandleFunc("POST /api/login", s.handleLogin)
mux.HandleFunc("POST /api/logout", s.handleLogout)
mux.HandleFunc("POST /api/setup", s.handleSetup)
// Всё остальное — только для вошедших.
protected := map[string]http.HandlerFunc{
"GET /api/status": s.handleStatus,
"POST /api/server/start": s.handleStart,
"POST /api/server/stop": s.handleStop,
"POST /api/server/restart": s.handleRestart,
"POST /api/console": s.handleConsole,
"GET /api/logs": s.handleLogs,
"GET /api/logs/stream": s.handleLogStream,
"GET /api/config/server": s.handleGetServerConfig,
"PUT /api/config/server": s.handlePutServerConfig,
"GET /api/config/sandbox": s.handleGetSandbox,
"PUT /api/config/sandbox": s.handlePutSandbox,
"GET /api/config/manager": s.handleGetManagerConfig,
"PUT /api/config/manager": s.handlePutManagerConfig,
"GET /api/profiles": s.handleListProfiles,
"POST /api/profiles": s.handleCreateProfile,
"PUT /api/profiles/{id}": s.handleUpdateProfile,
"DELETE /api/profiles/{id}": s.handleDeleteProfile,
"POST /api/profiles/{id}/activate": s.handleActivateProfile,
"POST /api/presets/preview": s.handlePreviewPreset,
"POST /api/presets/import": s.handleImportPreset,
"GET /api/mods": s.handleGetMods,
"PUT /api/mods": s.handlePutMods,
"POST /api/mods/lookup": s.handleLookupMods,
"POST /api/mods/collection": s.handleExpandCollection,
"GET /api/backups": s.handleListBackups,
"POST /api/backups": s.handleCreateBackup,
"DELETE /api/backups/{name}": s.handleDeleteBackup,
"GET /api/backups/{name}/download": s.handleDownloadBackup,
"POST /api/backups/{name}/restore": s.handleRestoreBackup,
"POST /api/install": s.handleInstall,
"GET /api/update": s.handleCheckUpdate,
"POST /api/update": s.handleApplyUpdate,
"POST /api/manager/restart": s.handleRestartManager,
"GET /api/task": s.handleTaskStatus,
"POST /api/task/cancel": s.handleTaskCancel,
"GET /api/users": s.handleListUsers,
"POST /api/users": s.handleCreateUser,
"DELETE /api/users/{login}": s.handleDeleteUser,
"POST /api/users/{login}/password": s.handleChangePassword,
}
for pattern, handler := range protected {
mux.Handle(pattern, s.requireAuth(handler))
}
mux.Handle("GET /", s.staticHandler())
return s.withCommonHeaders(mux)
}
// staticHandler отдаёт SPA: неизвестные пути возвращают index.html, чтобы
// работали ссылки вида /mods при перезагрузке страницы.
func (s *Server) staticHandler() http.Handler {
files := http.FileServer(http.FS(s.static))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
if _, err := fs.Stat(s.static, path); err != nil {
r = r.Clone(r.Context())
r.URL.Path = "/"
}
files.ServeHTTP(w, r)
})
}
// withCommonHeaders ставит заголовки безопасности и проверяет CSRF.
func (s *Server) withCommonHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
// Панель полностью самодостаточна: внешние скрипты ей не нужны.
w.Header().Set("Content-Security-Policy",
"default-src 'self'; img-src 'self' data: https://steamuserimages-a.akamaihd.net "+
"https://images.steamusercontent.com https://community.cloudflare.steamstatic.com; "+
"style-src 'self'; script-src 'self'; connect-src 'self'")
if r.Method != http.MethodGet && r.Method != http.MethodHead &&
strings.HasPrefix(r.URL.Path, "/api/") && r.Header.Get(csrfHeader) == "" {
writeError(w, r, http.StatusForbidden, "запрос без заголовка "+csrfHeader)
return
}
next.ServeHTTP(w, r)
})
}
// requireAuth пропускает дальше только запросы с живой сессией.
func (s *Server) requireAuth(next http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := s.users.SessionFromRequest(r); !ok {
writeError(w, r, http.StatusUnauthorized, "требуется вход")
return
}
next(w, r)
})
}
// setSessionCookie кладёт cookie сессии.
func (s *Server) setSessionCookie(w http.ResponseWriter, id string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookie,
Value: id,
Path: "/",
HttpOnly: true,
Secure: s.secureCookies,
SameSite: http.SameSiteStrictMode,
MaxAge: int(ttl.Seconds()),
})
}
func (s *Server) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookie,
Value: "",
Path: "/",
HttpOnly: true,
Secure: s.secureCookies,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
}
// writeJSON отправляет значение как JSON.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("api: не удалось записать ответ: %v", err)
}
}
// writeError отправляет ошибку в том же формате, что и остальные ответы.
// Текст переводится на язык панели: в коде сообщения написаны по-русски.
func writeError(w http.ResponseWriter, r *http.Request, status int, message string) {
writeJSON(w, status, map[string]string{"error": i18n.T(i18n.FromRequest(r), message)})
}
// writeOpError переводит ошибку операции в подходящий HTTP-код.
func writeOpError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, pzserver.ErrAlreadyRunning), errors.Is(err, pzserver.ErrNotRunning),
errors.Is(err, job.ErrBusy):
writeError(w, r, http.StatusConflict, err.Error())
case errors.Is(err, pzserver.ErrNotInstalled):
writeError(w, r, http.StatusPreconditionFailed, err.Error())
default:
writeError(w, r, http.StatusInternalServerError, err.Error())
}
}
// decodeJSON читает тело запроса, ограничивая его размер.
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
writeError(w, r, http.StatusBadRequest, "некорректный JSON: "+err.Error())
return false
}
return true
}