dana-medical-site/internal/api/admin.go
2026-10-14 13:33:13 +00:00

319 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package api
import (
"encoding/json"
"errors"
"io"
"net"
"net/http"
"strconv"
"strings"
"time"
"dana/internal/storage"
)
const sessionCookie = "dana_admin"
type ctxKey string
const userKey ctxKey = "admin_user"
// trustedProxies — откуда принимаем X-Forwarded-For / X-Real-IP. По умолчанию сам сервер и Docker-сети:
// порт сайта открыт только внутри сервера (172.17.0.1), снаружи к нему ходит лишь Caddy.
// Заголовки от любого другого адреса — подделка клиента для обхода лимитов по IP.
var trustedProxies = mustParseProxies("127.0.0.0/8,::1/128,172.16.0.0/12")
// SetTrustedProxies задаёт доверенные прокси (TRUSTED_PROXIES): адреса или подсети через запятую,
// например адрес Caddy в Docker-сети. Пустая строка оставляет значение по умолчанию.
func SetTrustedProxies(spec string) error {
if strings.TrimSpace(spec) == "" {
return nil
}
nets, err := parseProxies(spec)
if err != nil {
return err
}
trustedProxies = nets
return nil
}
func parseProxies(spec string) ([]*net.IPNet, error) {
var out []*net.IPNet
for _, part := range strings.Split(spec, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if !strings.Contains(part, "/") {
if ip := net.ParseIP(part); ip != nil && ip.To4() != nil {
part += "/32"
} else {
part += "/128"
}
}
_, n, err := net.ParseCIDR(part)
if err != nil {
return nil, errors.New("TRUSTED_PROXIES: не адрес и не подсеть: " + part)
}
out = append(out, n)
}
return out, nil
}
func mustParseProxies(spec string) []*net.IPNet {
n, err := parseProxies(spec)
if err != nil {
panic(err)
}
return n
}
func isTrustedProxy(ip net.IP) bool {
for _, n := range trustedProxies {
if n.Contains(ip) {
return true
}
}
return false
}
// clientIP определяет адрес посетителя. Заголовки прокси учитываем, только если соединение
// пришло от доверенного прокси; иначе берём адрес соединения.
// Первым смотрим X-Forwarded-For: его Caddy формирует сам (входящий от посетителя отбрасывает),
// а X-Real-IP Caddy без отдельной настройки пропускает от посетителя как есть.
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
remote := net.ParseIP(host)
if remote == nil || !isTrustedProxy(remote) {
return host
}
// справа налево: правые элементы дописали наши прокси, первый чужой — клиент
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
for i := len(parts) - 1; i >= 0; i-- {
ip := net.ParseIP(strings.TrimSpace(parts[i]))
if ip == nil {
break
}
if !isTrustedProxy(ip) {
return ip.String()
}
}
}
if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); net.ParseIP(real) != nil {
return real
}
return host
}
// drainBody дочитывает небольшое тело запроса перед отказом. Если ответить, не прочитав его,
// а клиент дошлёт тело уже после закрытия соединения, Windows рвёт соединение (RST),
// и клиент вместо ответа 401 получает ошибку сети.
func drainBody(r *http.Request) {
_, _ = io.Copy(io.Discard, io.LimitReader(r.Body, 1<<20))
}
// requireAuth пропускает только запросы с действующей сессией.
func (a *API) requireAuth(next func(http.ResponseWriter, *http.Request, *storage.AdminUser)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(sessionCookie)
if err != nil || c.Value == "" {
drainBody(r)
fail(w, http.StatusUnauthorized, "unauthorized", "Требуется вход")
return
}
u, err := a.store.UserByToken(r.Context(), c.Value)
if err != nil {
drainBody(r)
fail(w, http.StatusUnauthorized, "unauthorized", "Сессия истекла")
return
}
next(w, r, u)
}
}
func (a *API) adminLogin(w http.ResponseWriter, r *http.Request) {
// защита от перебора пароля: не больше 10 попыток входа с одного IP за 15 минут
if a.tooMany(w, "login:"+clientIP(r), 10, 15*time.Minute) {
return
}
var in struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
return
}
token, u, err := a.store.Login(r.Context(),
strings.TrimSpace(strings.ToLower(in.Email)), in.Password,
clientIP(r), r.UserAgent())
if errors.Is(err, storage.ErrBadCredentials) {
fail(w, http.StatusUnauthorized, "bad_credentials", "Неверный логин или пароль")
return
}
if err != nil {
handleErr(w, err)
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 12 * 3600,
})
ok(w, u, nil)
}
func (a *API) adminLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookie); err == nil {
_ = a.store.Logout(r.Context(), c.Value)
}
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: -1})
ok(w, map[string]string{"status": "ok"}, nil)
}
func (a *API) adminMe(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
ok(w, u, nil)
}
// ---------- Услуги ----------
func (a *API) adminServices(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
list, err := a.store.AdminServices(r.Context(), r.URL.Query().Get("q"))
if err != nil {
handleErr(w, err)
return
}
ok(w, list, map[string]any{"total": len(list)})
}
func (a *API) adminUpdateService(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
return
}
var in storage.ServiceUpdate
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
return
}
if strings.TrimSpace(in.Name) == "" {
fail(w, http.StatusUnprocessableEntity, "validation", "Название не может быть пустым")
return
}
if err := a.store.AdminUpdateService(r.Context(), id, in); err != nil {
handleErr(w, err)
return
}
a.store.Audit(r.Context(), u.ID, "update", "service", id, clientIP(r))
ok(w, map[string]any{"id": id}, nil)
}
// ---------- Прайс ----------
func (a *API) adminPriceSections(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
list, err := a.store.AdminPriceSections(r.Context())
if err != nil {
handleErr(w, err)
return
}
ok(w, list, nil)
}
func (a *API) adminPriceItems(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
q := r.URL.Query()
sectionID, _ := strconv.ParseInt(q.Get("section_id"), 10, 64)
limit, _ := strconv.Atoi(q.Get("limit"))
if limit <= 0 || limit > 200 {
limit = 50
}
offset, _ := strconv.Atoi(q.Get("offset"))
if offset < 0 {
offset = 0
}
list, total, err := a.store.AdminPriceItems(r.Context(), sectionID, q.Get("q"), limit, offset)
if err != nil {
handleErr(w, err)
return
}
ok(w, list, map[string]any{"total": total, "limit": limit, "offset": offset})
}
func validPriceInput(in storage.PriceItemInput) string {
if strings.TrimSpace(in.Name) == "" {
return "Название не может быть пустым"
}
if in.Price < 0 {
return "Цена не может быть отрицательной"
}
if in.SectionID == 0 {
return "Не выбран раздел"
}
return ""
}
func (a *API) adminUpdatePriceItem(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
return
}
var in storage.PriceItemInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
return
}
if msg := validPriceInput(in); msg != "" {
fail(w, http.StatusUnprocessableEntity, "validation", msg)
return
}
if err := a.store.AdminUpdatePriceItem(r.Context(), id, in); err != nil {
handleErr(w, err)
return
}
a.store.Audit(r.Context(), u.ID, "update", "price_item", id, clientIP(r))
ok(w, map[string]any{"id": id}, nil)
}
func (a *API) adminCreatePriceItem(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
var in storage.PriceItemInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
return
}
if msg := validPriceInput(in); msg != "" {
fail(w, http.StatusUnprocessableEntity, "validation", msg)
return
}
id, err := a.store.AdminCreatePriceItem(r.Context(), in)
if err != nil {
handleErr(w, err)
return
}
a.store.Audit(r.Context(), u.ID, "create", "price_item", id, clientIP(r))
ok(w, map[string]any{"id": id}, nil)
}
func (a *API) adminDeletePriceItem(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
return
}
if err := a.store.AdminDeletePriceItem(r.Context(), id); err != nil {
handleErr(w, err)
return
}
a.store.Audit(r.Context(), u.ID, "delete", "price_item", id, clientIP(r))
ok(w, map[string]any{"id": id}, nil)
}