// 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, "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 }