278 lines
10 KiB
Go
278 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"telesrv/internal/admin"
|
|
)
|
|
|
|
// serverManage gates the whole Server Settings surface -- see
|
|
// permissionServerManage's doc comment in security.go for why this is one
|
|
// right rather than split review/manage like other sections.
|
|
func (s *server) serverManage(handler http.HandlerFunc) http.Handler {
|
|
return s.requireAuthAPI(s.requirePermission(permissionServerManage, handler))
|
|
}
|
|
|
|
// serverCommandResult builds the same admin.CommandResult shape every other
|
|
// action returns, without going through internal/admin's runCommand +
|
|
// Postgres audit log: everything in this file operates on local files/
|
|
// processes directly (see routes() in server.go for why), so there is no
|
|
// owpengram-server-side admin_commands row to write. The actor/reason are
|
|
// still in meta for structured logging if that's ever added; today they are
|
|
// simply not persisted anywhere.
|
|
func serverCommandResult(meta admin.CommandMeta, action string, err error, message string, details map[string]any) admin.CommandResult {
|
|
status := "completed"
|
|
errText := ""
|
|
if err != nil {
|
|
status = "failed"
|
|
errText = err.Error()
|
|
if message == "" {
|
|
message = "command failed"
|
|
}
|
|
}
|
|
return admin.CommandResult{
|
|
CommandID: meta.CommandID,
|
|
Action: action,
|
|
Status: status,
|
|
DryRun: meta.DryRun,
|
|
Message: message,
|
|
Details: details,
|
|
Error: errText,
|
|
}
|
|
}
|
|
|
|
// --- identity (name/description/icon) ---------------------------------
|
|
|
|
func (s *server) handleServerIdentityAPI(w http.ResponseWriter, r *http.Request) {
|
|
info, err := s.identity.Get()
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, info)
|
|
}
|
|
|
|
// handleServerIconAPI serves the icon's raw bytes for the panel's own
|
|
// preview -- separate from owpengram-server's public /owpengram/server-icon
|
|
// (same underlying file, different process/auth: this one is behind the
|
|
// admin session, not open to clients).
|
|
func (s *server) handleServerIconAPI(w http.ResponseWriter, r *http.Request) {
|
|
data, ext, ok := s.identity.Icon()
|
|
if !ok {
|
|
writeAPIError(w, http.StatusNotFound, "no icon configured")
|
|
return
|
|
}
|
|
contentType := map[string]string{
|
|
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
".webp": "image/webp", ".gif": "image/gif",
|
|
}[ext]
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
w.Header().Set("Content-Type", contentType)
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = w.Write(data)
|
|
}
|
|
|
|
type setServerIdentityAPIRequest struct {
|
|
CommandID string `json:"command_id"`
|
|
Reason string `json:"reason"`
|
|
Confirm bool `json:"confirm"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
func (s *server) handleSetServerIdentityAPI(w http.ResponseWriter, r *http.Request) {
|
|
var body setServerIdentityAPIRequest
|
|
if !decodeAction(w, r, &body) {
|
|
return
|
|
}
|
|
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-server-identity")
|
|
details := map[string]any{"name": body.Name, "description": body.Description}
|
|
if meta.DryRun {
|
|
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_identity", nil, "server identity validated", details))
|
|
return
|
|
}
|
|
err := s.identity.SetText(body.Name, body.Description)
|
|
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_identity", err, "server identity updated", details))
|
|
}
|
|
|
|
var allowedServerIconExts = map[string]bool{
|
|
".png": true, ".jpg": true, ".jpeg": true, ".webp": true, ".gif": true,
|
|
}
|
|
|
|
const maxServerIconBytes = 2 << 20 // 2 MiB
|
|
|
|
type uploadServerIconAPIRequest struct {
|
|
CommandID string `json:"command_id"`
|
|
Reason string `json:"reason"`
|
|
Confirm bool `json:"confirm"`
|
|
}
|
|
|
|
// handleUploadServerIconAPI takes multipart/form-data (a "metadata" JSON
|
|
// field + a "file" field), the same shape handleSetAccountAvatarAPI uses --
|
|
// deliberately not JSON+base64 like the other Server Settings actions:
|
|
// base64 inflates a file ~33%, and decodeAction's plain io.LimitReader caps
|
|
// the request body at 1MiB regardless of maxServerIconBytes, so a real
|
|
// multi-hundred-KB icon would fail decoding ("unexpected EOF" from the
|
|
// truncated body) before this handler ever saw it. Multipart sidesteps that
|
|
// entirely -- the size cap below is enforced on the actual file bytes.
|
|
func (s *server) handleUploadServerIconAPI(w http.ResponseWriter, r *http.Request) {
|
|
defer r.Body.Close()
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxServerIconBytes+(1<<20))
|
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
|
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
|
return
|
|
}
|
|
if r.MultipartForm != nil {
|
|
defer r.MultipartForm.RemoveAll()
|
|
}
|
|
var body uploadServerIconAPIRequest
|
|
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(&body); err != nil {
|
|
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
|
return
|
|
}
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusBadRequest, "icon file is required")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
ext := strings.ToLower(filepath.Ext(header.Filename))
|
|
if !allowedServerIconExts[ext] {
|
|
writeAPIError(w, http.StatusBadRequest, "unsupported icon extension")
|
|
return
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(file, maxServerIconBytes+1))
|
|
if err != nil || len(data) == 0 || len(data) > maxServerIconBytes {
|
|
writeAPIError(w, http.StatusBadRequest, "icon file is empty or too large (max 2MiB)")
|
|
return
|
|
}
|
|
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "upload-server-icon")
|
|
details := map[string]any{"bytes": len(data), "ext": ext}
|
|
if meta.DryRun {
|
|
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.upload_icon", nil, "server icon validated", details))
|
|
return
|
|
}
|
|
setErr := s.identity.SetIcon(data, ext)
|
|
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.upload_icon", setErr, "server icon updated", details))
|
|
}
|
|
|
|
type removeServerIconAPIRequest struct {
|
|
CommandID string `json:"command_id"`
|
|
Reason string `json:"reason"`
|
|
Confirm bool `json:"confirm"`
|
|
}
|
|
|
|
func (s *server) handleRemoveServerIconAPI(w http.ResponseWriter, r *http.Request) {
|
|
var body removeServerIconAPIRequest
|
|
if !decodeAction(w, r, &body) {
|
|
return
|
|
}
|
|
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "remove-server-icon")
|
|
if meta.DryRun {
|
|
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.remove_icon", nil, "server icon removal validated", nil))
|
|
return
|
|
}
|
|
err := s.identity.RemoveIcon()
|
|
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.remove_icon", err, "server icon removed", nil))
|
|
}
|
|
|
|
// --- .env editing --------------------------------------------------------
|
|
|
|
func (s *server) handleServerEnvAPI(w http.ResponseWriter, r *http.Request) {
|
|
groups, err := s.serverCtl.ReadEnvGroups()
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, groups)
|
|
}
|
|
|
|
type updateServerEnvAPIRequest struct {
|
|
CommandID string `json:"command_id"`
|
|
Reason string `json:"reason"`
|
|
Confirm bool `json:"confirm"`
|
|
Values map[string]string `json:"values"`
|
|
}
|
|
|
|
func (s *server) handleUpdateServerEnvAPI(w http.ResponseWriter, r *http.Request) {
|
|
var body updateServerEnvAPIRequest
|
|
if !decodeAction(w, r, &body) {
|
|
return
|
|
}
|
|
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "update-server-env")
|
|
details := map[string]any{"keys_changed": len(body.Values)}
|
|
if meta.DryRun {
|
|
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.update_env", nil, "would update .env -- takes effect on next Restart/Update", details))
|
|
return
|
|
}
|
|
err := s.serverCtl.WriteEnvValues(body.Values)
|
|
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.update_env", err, ".env updated -- restart the server for changes to take effect", details))
|
|
}
|
|
|
|
// --- status / restart / update -------------------------------------------
|
|
|
|
func (s *server) handleServerStatusAPI(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, s.serverCtl.Status())
|
|
}
|
|
|
|
// handleDockerStatusAPI backs the Services tab's live container list
|
|
// (postgres/redis/minio) -- see procctl.Manager.DockerStatus. A "docker
|
|
// compose ps" failure (daemon not running, compose file missing) is
|
|
// reported as an API error rather than an empty list, so the frontend can
|
|
// tell "no services" apart from "couldn't ask Docker".
|
|
func (s *server) handleDockerStatusAPI(w http.ResponseWriter, r *http.Request) {
|
|
services, err := s.serverCtl.DockerStatus(r.Context())
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, services)
|
|
}
|
|
|
|
type restartServerAPIRequest struct {
|
|
CommandID string `json:"command_id"`
|
|
Reason string `json:"reason"`
|
|
Confirm bool `json:"confirm"`
|
|
}
|
|
|
|
func (s *server) handleRestartServerAPI(w http.ResponseWriter, r *http.Request) {
|
|
var body restartServerAPIRequest
|
|
if !decodeAction(w, r, &body) {
|
|
return
|
|
}
|
|
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "restart-server")
|
|
if meta.DryRun {
|
|
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.restart", nil, "restart validated -- rebuilds and relaunches bin/owpengram-server", nil))
|
|
return
|
|
}
|
|
log, err := s.serverCtl.Restart(r.Context())
|
|
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.restart", err, "server restarted", map[string]any{"log": log}))
|
|
}
|
|
|
|
type updateServerAPIRequest struct {
|
|
CommandID string `json:"command_id"`
|
|
Reason string `json:"reason"`
|
|
Confirm bool `json:"confirm"`
|
|
}
|
|
|
|
func (s *server) handleUpdateServerAPI(w http.ResponseWriter, r *http.Request) {
|
|
var body updateServerAPIRequest
|
|
if !decodeAction(w, r, &body) {
|
|
return
|
|
}
|
|
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "update-server")
|
|
if meta.DryRun {
|
|
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.update", nil, "update validated -- git pull, rebuild both binaries, relaunch bin/owpengram-server (admin panel binary is rebuilt but not self-restarted)", nil))
|
|
return
|
|
}
|
|
log, err := s.serverCtl.Update(r.Context())
|
|
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.update", err, "server updated", map[string]any{"log": log}))
|
|
}
|