415 lines
11 KiB
Go
415 lines
11 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/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
|
|
}
|
|
var link *string
|
|
if s.App.Cfg.TGBotUsername != "" && portalID != "" {
|
|
v := "https://t.me/" + s.App.Cfg.TGBotUsername + "?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(s.App.Cfg.TGBotUsername),
|
|
"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})
|
|
}
|