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) }