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