package webapp import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strings" "time" "rocketgit.ru/rsmon/worker/internal/compose" ) // Compose management endpoints. The browser UI uses session+CSRF POSTs // under /compose; scripted callers use HTTP basic auth under // /web/api/compose (the /web/api/* prefix is the only one the auth // middleware accepts basic credentials on). Read endpoints (list, // detail, logs) are GET; lifecycle endpoints (up/down/stop/restart/pull) // are POST. Every mutation is audited via the shared webapp audit log. // handleComposeList renders the project list page. func (s *Server) handleComposeList(w http.ResponseWriter, r *http.Request) { writeNoStore(w) sess, _ := sessionFromContext(r.Context()) data := composeListPageData{ basePageData: s.newBasePage(r, "Compose projects", sess), Snapshot: s.compose.Snapshot(), LastAt: s.compose.LastRefreshAt(), Enabled: s.compose.Enabled(), } if err := s.templates.Execute(w, "compose.html", data); err != nil { s.deps.Logger.Printf("render compose: %v", err) http.Error(w, "template error", http.StatusInternalServerError) } } // handleComposeDetail renders one project with its services, containers, // mounts, and Traefik routes, plus the management action buttons. func (s *Server) handleComposeDetail(w http.ResponseWriter, r *http.Request) { writeNoStore(w) sess, _ := sessionFromContext(r.Context()) snap := s.compose.Snapshot() p := snap.FindProject(r.PathValue("project")) if p == nil { http.NotFound(w, r) return } data := composeDetailPageData{ basePageData: s.newBasePage(r, "Compose: "+p.Name, sess), Project: p, LastAt: s.compose.LastRefreshAt(), ActionOK: r.URL.Query().Get("ok") == "1", ActionMessage: r.URL.Query().Get("msg"), } if err := s.templates.Execute(w, "compose_detail.html", data); err != nil { s.deps.Logger.Printf("render compose detail: %v", err) http.Error(w, "template error", http.StatusInternalServerError) } } // handleComposeLogsPage renders the recent log tail for a project in a //
 block so an operator can read it from the browser without curl.
func (s *Server) handleComposeLogsPage(w http.ResponseWriter, r *http.Request) {
	writeNoStore(w)
	sess, _ := sessionFromContext(r.Context())
	project := r.PathValue("project")
	res, status := s.composeLogsResult(r.Context(), project)
	if status != http.StatusOK {
		http.Error(w, res.Output, status)
		return
	}
	data := composeLogsPageData{
		basePageData: s.newBasePage(r, "Compose logs: "+project, sess),
		Project:      project,
		Output:       res.Output,
	}
	if err := s.templates.Execute(w, "compose_logs.html", data); err != nil {
		s.deps.Logger.Printf("render compose logs: %v", err)
		http.Error(w, "template error", http.StatusInternalServerError)
	}
}

// handleComposeProjectAction runs a lifecycle action against a project
// from the HTML form (session+CSRF) or the API (basic auth). The
// response is a redirect for the browser path and JSON for the API path.
func (s *Server) handleComposeProjectAction(w http.ResponseWriter, r *http.Request) {
	if !s.compose.Enabled() {
		http.Error(w, "compose management is disabled", http.StatusServiceUnavailable)
		return
	}
	if !validProjectPostAction(r.PathValue("action")) {
		http.NotFound(w, r)
		return
	}
	if !s.composeMutationAuthorized(w, r) {
		return
	}
	project := r.PathValue("project")
	action := r.PathValue("action")
	refs, ok := s.compose.Refs(project)
	if !ok {
		http.Error(w, "compose project working directory is unknown", http.StatusNotFound)
		return
	}
	res, _ := compose.ManageProject(r.Context(), refs, compose.Action(action))
	s.auditCompose(r, action, project, "")
	s.respondComposeAction(w, r, project, res)
}

// handleComposeServiceAction runs a lifecycle action against a single
// service inside a project.
func (s *Server) handleComposeServiceAction(w http.ResponseWriter, r *http.Request) {
	if !s.compose.Enabled() {
		http.Error(w, "compose management is disabled", http.StatusServiceUnavailable)
		return
	}
	if _, ok := compose.ValidServiceAction(r.PathValue("action")); !ok {
		http.NotFound(w, r)
		return
	}
	if !s.composeMutationAuthorized(w, r) {
		return
	}
	project := r.PathValue("project")
	service := r.PathValue("service")
	action := r.PathValue("action")
	refs, ok := s.compose.Refs(project)
	if !ok {
		http.Error(w, "compose project working directory is unknown", http.StatusNotFound)
		return
	}
	res, _ := compose.ManageService(r.Context(), refs, service, compose.Action(action))
	s.auditCompose(r, action, project, service)
	s.respondComposeAction(w, r, project, res)
}

// handleComposeAPIList returns the full discovery snapshot as JSON. The
// shape matches the /compose HTML page (a Snapshot: projects + errors).
func (s *Server) handleComposeAPIList(w http.ResponseWriter, r *http.Request) {
	writeComposeJSON(w, s.compose.Snapshot())
}

// handleComposeAPIDetail returns one project summary as JSON.
func (s *Server) handleComposeAPIDetail(w http.ResponseWriter, r *http.Request) {
	p := s.compose.Snapshot().FindProject(r.PathValue("project"))
	if p == nil {
		http.Error(w, "compose project not found", http.StatusNotFound)
		return
	}
	writeComposeJSON(w, p)
}

// handleComposeLogsAPI returns the recent log tail for a project as JSON.
func (s *Server) handleComposeLogsAPI(w http.ResponseWriter, r *http.Request) {
	res, status := s.composeLogsResult(r.Context(), r.PathValue("project"))
	if status != http.StatusOK {
		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		w.WriteHeader(status)
		_ = json.NewEncoder(w).Encode(res)
		return
	}
	writeComposeJSON(w, res)
}

// composeLogsResult resolves the log tail for a project and the HTTP
// status the caller should write. A 404 means the project (or its
// working directory) is unknown; 502 means Docker refused the call.
func (s *Server) composeLogsResult(ctx context.Context, project string) (compose.ManagementResult, int) {
	refs, ok := s.compose.Refs(project)
	if !ok {
		return compose.ManagementResult{OK: false, Action: "logs", Output: "compose project working directory is unknown"}, http.StatusNotFound
	}
	res, err := compose.Logs(ctx, refs, 300)
	if err != nil && res.Output == "" {
		return res, http.StatusBadGateway
	}
	return res, http.StatusOK
}

// composeMutationAuthorized enforces CSRF for browser (session) callers
// while letting basic-auth API callers through without a token (the
// middleware already validated their credentials). Returns true when the
// request may proceed; writes a 403 and returns false otherwise.
func (s *Server) composeMutationAuthorized(w http.ResponseWriter, r *http.Request) bool {
	sess, hasSess := sessionFromContext(r.Context())
	if !hasSess {
		return true // basic-auth API path; middleware authenticated it
	}
	if !s.requireCSRF(sess, r) {
		http.Error(w, "csrf token required", http.StatusForbidden)
		return false
	}
	return true
}

// validProjectPostAction reports whether name is a POST-able project
// action. It reuses compose.ValidProjectAction but excludes "logs",
// which is served by a dedicated GET route.
func validProjectPostAction(name string) bool {
	if name == string(compose.ActionLogs) {
		return false
	}
	_, ok := compose.ValidProjectAction(name)
	return ok
}

// respondComposeAction sends the action result to the right consumer:
// JSON for /web/api/* (scripting), a redirect with a flash banner for
// the browser path. The action's combined output is URL-encoded into the
// banner so the operator sees what Compose printed without a separate
// request; very large output is truncated to keep the URL bounded.
func (s *Server) respondComposeAction(w http.ResponseWriter, r *http.Request, project string, res compose.ManagementResult) {
	if strings.HasPrefix(r.URL.Path, "/web/api/") {
		writeComposeJSON(w, res)
		return
	}
	msg := strings.TrimSpace(res.Output)
	if len(msg) > 500 {
		msg = msg[:500] + "…"
	}
	if !res.OK && msg == "" {
		msg = res.Action + " failed"
	}
	loc := fmt.Sprintf("/compose/%s?ok=%t&msg=%s", project, res.OK, url.QueryEscape(msg))
	http.Redirect(w, r, loc, http.StatusSeeOther)
}

// writeComposeJSON serializes a value as no-store JSON. Used by every
// /web/api/compose endpoint so scripted callers get one consistent shape.
func writeComposeJSON(w http.ResponseWriter, v interface{}) {
	writeNoStore(w)
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	_ = json.NewEncoder(w).Encode(v)
}

// auditCompose records a management action in the worker audit log. The
// target encodes project and service so the audit trail reconstructs the
// exact scope (e.g. "compose:rsmon/web"); failures are still audited so
// an operator can trace a stopped service back to the action.
func (s *Server) auditCompose(r *http.Request, action, project, service string) {
	target := "compose:" + project
	if service != "" {
		target += "/" + service
	}
	authMode := auditAuthModeBasic
	if _, hasSess := sessionFromContext(r.Context()); hasSess {
		authMode = auditAuthModeLocal
	}
	_ = s.store.WriteAudit(r.Context(), &AuditEntry{
		Actor:    auditActorLocal,
		Role:     auditRoleAdmin,
		AuthMode: authMode,
		IP:       clientIP(r),
		UA:       r.UserAgent(),
		Action:   "compose_" + action,
		Target:   target,
	})
}

type composeListPageData struct {
	basePageData
	Snapshot compose.Snapshot
	LastAt   time.Time
	Enabled  bool
}

type composeDetailPageData struct {
	basePageData
	Project       *compose.ProjectSummary
	LastAt        time.Time
	ActionOK      bool
	ActionMessage string
}

type composeLogsPageData struct {
	basePageData
	Project string
	Output  string
}