Вместо двух отдельных списков (Workshop ID и Mod ID) — один список модов: галочка включает мод, перетаскивание задаёт порядок загрузки, кнопка убирает пакет вместе со всеми его модами. Чтобы галочкам было за что зацепиться, панель читает mod.info уже скачанных модов: серверу в Mods нужен именно Mod ID, а он лежит внутри пакета мастерской и вручную его приходилось искать в описании мода. Пока мод не скачан, строка помечена и галочка недоступна — Mod ID неизвестен. Один пакет мастерской может содержать несколько модов; теперь они видны отдельными строками и включаются по отдельности. Коллекции: кнопка разворачивает коллекцию Steam в список входящих в неё модов, сохраняя порядок со страницы коллекции. И мод, и коллекция принимаются как ссылкой, так и голым идентификатором. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
240 lines
9.2 KiB
Go
240 lines
9.2 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/job"
|
|
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
|
"github.com/mrleo1nid/pzmanager/internal/pzserver"
|
|
"github.com/mrleo1nid/pzmanager/internal/steamcmd"
|
|
)
|
|
|
|
// csrfHeader — заголовок, который обязан прислать клиент при изменяющих
|
|
// запросах. Браузер не поставит его в кросс-сайтовом простом запросе, поэтому
|
|
// он служит защитой от CSRF без отдельных токенов.
|
|
const csrfHeader = "X-PZM-Request"
|
|
|
|
// Server связывает HTTP-слой со всеми подсистемами менеджера.
|
|
type Server struct {
|
|
cfg *config.Config
|
|
users *auth.Store
|
|
sup *pzserver.Supervisor
|
|
jobs *job.Manager
|
|
backups *backup.Manager
|
|
steam *steamcmd.Client
|
|
workshop *pzconfig.WorkshopClient
|
|
static fs.FS
|
|
// secureCookies включает флаг Secure у cookie сессии (панель за HTTPS).
|
|
secureCookies bool
|
|
// setupToken защищает первичное создание администратора: менеджер печатает
|
|
// его в журнал при старте, когда учёток ещё нет.
|
|
setupToken string
|
|
}
|
|
|
|
// Options — то, что нужно собрать серверу API.
|
|
type Options struct {
|
|
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
|
|
}
|
|
|
|
// New собирает HTTP-обработчик панели.
|
|
func New(o Options) *Server {
|
|
return &Server{
|
|
cfg: o.Config,
|
|
users: o.Users,
|
|
sup: o.Supervisor,
|
|
jobs: o.Jobs,
|
|
backups: o.Backups,
|
|
steam: o.Steam,
|
|
workshop: pzconfig.NewWorkshopClient(),
|
|
static: o.Static,
|
|
secureCookies: o.SecureCookies,
|
|
setupToken: o.SetupToken,
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
"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/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, 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, 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, status int, message string) {
|
|
writeJSON(w, status, map[string]string{"error": message})
|
|
}
|
|
|
|
// writeOpError переводит ошибку операции в подходящий HTTP-код.
|
|
func writeOpError(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, pzserver.ErrAlreadyRunning), errors.Is(err, pzserver.ErrNotRunning),
|
|
errors.Is(err, job.ErrBusy):
|
|
writeError(w, http.StatusConflict, err.Error())
|
|
case errors.Is(err, pzserver.ErrNotInstalled):
|
|
writeError(w, http.StatusPreconditionFailed, err.Error())
|
|
default:
|
|
writeError(w, 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, http.StatusBadRequest, "некорректный JSON: "+err.Error())
|
|
return false
|
|
}
|
|
return true
|
|
}
|