Files
monitoring-pf/internal/pf/http.go
2026-06-05 12:31:52 +03:00

457 lines
12 KiB
Go

package pf
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
)
type Server struct {
App *App
}
type listingPayload struct {
URL string `json:"url"`
}
type bulkPayload struct {
URLs []string `json:"urls"`
}
func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := s.apiPath(r.URL.Path)
switch {
case path == "/healthz":
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
case path == "/":
writeJSON(w, http.StatusOK, map[string]string{"service": "monitoring-pf", "ui": "portal", "api": "go"})
case !strings.HasPrefix(path, "/api/v1"):
writeError(w, http.StatusNotFound, "not found")
case path == "/api/v1/access/me" && r.Method == http.MethodGet:
s.accessMe(w, r)
case path == "/api/v1/summary" && r.Method == http.MethodGet:
s.summary(w, r)
case path == "/api/v1/team/overview" && r.Method == http.MethodGet:
s.teamOverview(w, r)
case path == "/api/v1/employees":
s.employees(w, r)
case strings.HasPrefix(path, "/api/v1/employees/"):
s.employeeItem(w, r, path)
case path == "/api/v1/projects":
s.projects(w, r)
case strings.HasPrefix(path, "/api/v1/projects/"):
s.projectItem(w, r, path)
case strings.HasPrefix(path, "/api/v1/listings/"):
s.listingItem(w, r, path)
default:
writeError(w, http.StatusNotFound, "not found")
}
}
func (s Server) apiPath(path string) string {
base := s.App.Cfg.PublicBasePath
if base != "" && path == base {
return "/"
}
if base != "" && strings.HasPrefix(path, base+"/") {
return strings.TrimPrefix(path, base)
}
return path
}
func (s Server) accessMe(w http.ResponseWriter, r *http.Request) {
portalID := portalUserID(r)
emp, err := s.App.CurrentEmployee(r.Context(), portalID, false)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
botUsername := s.App.Cfg.TGBotUsername
if botUsername == "" {
if resolved, err := s.App.TG.BotUsername(r.Context()); err == nil {
botUsername = resolved
}
}
var link *string
if botUsername != "" && portalID != "" {
v := "https://t.me/" + botUsername + "?start=" + portalID
link = &v
}
var command *string
if portalID != "" {
v := "/start " + portalID
command = &v
}
writeJSON(w, http.StatusOK, map[string]any{
"is_admin": isAdmin(r),
"portal_user_id": nullablePlain(portalID),
"telegram_linked": emp != nil && emp.TGChatID != nil && *emp.TGChatID != "",
"can_view_team": canViewTeam(r),
"employee": emp,
"telegram_bot_username": nullablePlain(botUsername),
"telegram_start_command": command,
"telegram_start_link": link,
})
}
func (s Server) summary(w http.ResponseWriter, r *http.Request) {
emp, err := s.App.CurrentEmployee(r.Context(), portalUserID(r), false)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if emp != nil && emp.TGChatID == nil {
emp = nil
}
out, err := s.App.Summary(r.Context(), emp)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, out)
}
func (s Server) teamOverview(w http.ResponseWriter, r *http.Request) {
if !canViewTeam(r) {
writeError(w, http.StatusNotFound, "not found")
return
}
items, err := s.App.TeamOverview(r.Context(), subordinatePortalIDs(r), isAdmin(r))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, items)
}
func (s Server) employees(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
emp, err := s.App.CurrentEmployee(r.Context(), portalUserID(r), false)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
items, err := s.App.ListEmployees(r.Context(), isAdmin(r), emp)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, items)
case http.MethodPost:
if !isAdmin(r) {
writeError(w, http.StatusNotFound, "not found")
return
}
var payload EmployeePayload
if !decodeJSON(w, r, &payload) {
return
}
emp, err := s.App.CreateEmployee(r.Context(), payload)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, emp)
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func (s Server) employeeItem(w http.ResponseWriter, r *http.Request, path string) {
id, ok := pathID(w, strings.TrimPrefix(path, "/api/v1/employees/"))
if !ok {
return
}
if !isAdmin(r) {
writeError(w, http.StatusNotFound, "not found")
return
}
switch r.Method {
case http.MethodPatch:
var payload EmployeePayload
if !decodeJSON(w, r, &payload) {
return
}
emp, err := s.App.UpdateEmployee(r.Context(), id, payload)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, ErrNotFound) {
status = http.StatusNotFound
}
writeError(w, status, err.Error())
return
}
writeJSON(w, http.StatusOK, emp)
case http.MethodDelete:
err := s.App.DeleteEmployee(r.Context(), id)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, ErrNotFound) {
status = http.StatusNotFound
}
writeError(w, status, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func (s Server) projects(w http.ResponseWriter, r *http.Request) {
emp, ok := s.requireEmployee(w, r)
if !ok {
return
}
switch r.Method {
case http.MethodGet:
items, err := s.App.ListProjects(r.Context(), emp.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, items)
case http.MethodPost:
var payload ProjectPayload
if !decodeJSON(w, r, &payload) {
return
}
project, err := s.App.CreateProject(r.Context(), emp.ID, payload)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, project)
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func (s Server) projectItem(w http.ResponseWriter, r *http.Request, path string) {
emp, ok := s.requireEmployee(w, r)
if !ok {
return
}
rest := strings.TrimPrefix(path, "/api/v1/projects/")
parts := strings.Split(strings.Trim(rest, "/"), "/")
if len(parts) == 0 {
writeError(w, http.StatusNotFound, "not found")
return
}
projectID, ok := pathID(w, parts[0])
if !ok {
return
}
if len(parts) == 1 {
s.projectCRUD(w, r, emp.ID, projectID)
return
}
switch {
case len(parts) == 2 && parts[1] == "check" && r.Method == http.MethodPost:
if _, err := s.App.ProjectByID(r.Context(), emp.ID, projectID, false); err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
changes, err := s.App.Worker.CheckProject(r.Context(), projectID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]int{"changes": changes})
case len(parts) == 2 && parts[1] == "suggest" && r.Method == http.MethodGet:
if _, err := s.App.ProjectByID(r.Context(), emp.ID, projectID, false); err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
out, err := s.App.Worker.Suggest(r.Context(), projectID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, out)
case len(parts) == 2 && parts[1] == "listings" && r.Method == http.MethodPost:
s.addListing(w, r, emp.ID, projectID)
case len(parts) == 3 && parts[1] == "listings" && parts[2] == "bulk" && r.Method == http.MethodPost:
s.addListings(w, r, emp.ID, projectID)
default:
writeError(w, http.StatusNotFound, "not found")
}
}
func (s Server) projectCRUD(w http.ResponseWriter, r *http.Request, ownerID, projectID int64) {
switch r.Method {
case http.MethodGet:
project, err := s.App.ProjectByID(r.Context(), ownerID, projectID, true)
if err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
writeJSON(w, http.StatusOK, project)
case http.MethodPatch:
var payload ProjectPayload
if !decodeJSON(w, r, &payload) {
return
}
project, err := s.App.UpdateProject(r.Context(), ownerID, projectID, payload)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, ErrNotFound) {
status = http.StatusNotFound
}
writeError(w, status, err.Error())
return
}
writeJSON(w, http.StatusOK, project)
case http.MethodDelete:
if err := s.App.DeleteProject(r.Context(), ownerID, projectID); err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
w.WriteHeader(http.StatusNoContent)
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func (s Server) addListing(w http.ResponseWriter, r *http.Request, ownerID, projectID int64) {
if _, err := s.App.ProjectByID(r.Context(), ownerID, projectID, false); err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
var payload listingPayload
if !decodeJSON(w, r, &payload) {
return
}
id, err := s.App.Worker.AddListing(r.Context(), projectID, payload.URL)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
listing, err := s.App.ListingByID(r.Context(), id, true)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, listing)
}
func (s Server) addListings(w http.ResponseWriter, r *http.Request, ownerID, projectID int64) {
if _, err := s.App.ProjectByID(r.Context(), ownerID, projectID, false); err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
var payload bulkPayload
if !decodeJSON(w, r, &payload) {
return
}
out, err := s.App.Worker.AddListings(r.Context(), projectID, payload.URLs)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, out)
}
func (s Server) listingItem(w http.ResponseWriter, r *http.Request, path string) {
emp, ok := s.requireEmployee(w, r)
if !ok {
return
}
id, ok := pathID(w, strings.TrimPrefix(path, "/api/v1/listings/"))
if !ok {
return
}
if r.Method != http.MethodDelete {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if err := s.App.DeleteListing(r.Context(), emp.ID, id); err != nil {
writeError(w, http.StatusNotFound, "listing not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s Server) requireEmployee(w http.ResponseWriter, r *http.Request) (*Employee, bool) {
emp, err := s.App.CurrentEmployee(r.Context(), portalUserID(r), true)
if errors.Is(err, ErrTelegramRequired) {
writeError(w, http.StatusForbidden, "Сначала авторизуйтесь в Telegram-боте Monitoring PF")
return nil, false
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return nil, false
}
return emp, true
}
func portalUserID(r *http.Request) string {
return strings.TrimSpace(r.Header.Get("X-User-Id"))
}
func isAdmin(r *http.Request) bool {
return r.Header.Get("X-User-Is-Admin") == "1"
}
func canViewTeam(r *http.Request) bool {
return isAdmin(r) || r.Header.Get("X-User-Is-Department-Head") == "1"
}
func subordinatePortalIDs(r *http.Request) []string {
raw := strings.TrimSpace(r.Header.Get("X-User-Subordinates"))
if raw == "" {
return []string{}
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
id := strings.TrimSpace(part)
if id != "" {
out = append(out, id)
}
}
return out
}
func nullablePlain(value string) *string {
if strings.TrimSpace(value) == "" {
return nil
}
return &value
}
func pathID(w http.ResponseWriter, value string) (int64, bool) {
if strings.Contains(value, "/") {
writeError(w, http.StatusNotFound, "not found")
return 0, false
}
id, err := strconv.ParseInt(value, 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusNotFound, "not found")
return 0, false
}
return id, true
}
func decodeJSON(w http.ResponseWriter, r *http.Request, out any) bool {
defer r.Body.Close()
if err := json.NewDecoder(r.Body).Decode(out); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeError(w http.ResponseWriter, status int, detail string) {
writeJSON(w, status, map[string]string{"detail": detail})
}