319 lines
10 KiB
Go
319 lines
10 KiB
Go
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)
|
||
}
|