Netpulse_SasS/server/internal/httpapi/maps_write.go
byrsapty a4faf31fcd Етап 5: користувачі, вхід і права
Досі доступ давав машинний токен зі змінної збірки — одні права на всіх
і жодного способу відрізнити, хто що зробив. Тепер продукт уміє впустити
людину.

Сервер:
- argon2id для паролів, власний HS256 JWT (15 хв) + refresh-сесія в
  httpOnly-кукі на 30 днів з ротацією при кожному обміні
- Principal зводить людину й машинний токен до одного набору прав;
  права читаються з БД на кожному запиті, а не з claims, щоб відкликана
  роль не жила до кінця TTL
- 9 ендпоїнтів: auth/login|refresh|logout|password, me, team CRUD, roles
- netpulse-user — CLI для першого власника: публічна реєстрація в B2B
  це дірка, а «перший через веб, поки нікого немає» — нечесна гонка
- міграція 0012: три RLS-політики винятку для шляху входу (без них
  вхід неможливий за побудовою — щоб знайти користувача за email,
  треба знати тенант, який відомий лише після пошуку) і login_attempts
  для тротлінгу

Фронтенд:
- сторінка входу з вибором організації, access-токен у замиканні
  модуля замість localStorage, тихе відновлення сесії по кукі
- один refresh на всі паралельні запити: інакше ротація зробила б усі,
  крім першого, недійсними й викинула б людину на вхід
- дії без права не показуються; полотно нередаговане для глядача
- мобільний адаптив: висувна бічна панель, інспектор нижнім аркушем

11 нових тестів (37 у httpapi), go vet і tsc чисто, живий прогін з
8 кроків проти netpulse_it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 10:24:58 +03:00

157 lines
5.2 KiB
Go
Raw 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 httpapi
import (
"encoding/json"
"errors"
"net/http"
"github.com/netpulse/netpulse/server/internal/store"
)
// Обмеження тіла запиту. Полотно на 1000 вузлів у патчі — це ~1 МБ;
// 8 МБ лишає запас і водночас не дає одним запитом з'їсти пам'ять.
const maxPatchBody = 8 << 20
func (s *Server) handleCreateMap(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("maps:write") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
return
}
var in struct {
Name string `json:"name"`
Slug string `json:"slug"`
Kind string `json:"kind"`
}
if !decodeBody(w, r, &in) {
return
}
m, err := s.store.CreateMap(r.Context(), p.TenantID, in.Name, in.Slug, in.Kind)
if err != nil {
s.writeStoreError(w, "створення мапи", err)
return
}
writeJSON(w, http.StatusCreated, m)
}
func (s *Server) handleDeleteMap(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("maps:write") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
return
}
if err := s.store.DeleteMap(r.Context(), p.TenantID, r.PathValue("id")); err != nil {
s.writeStoreError(w, "видалення мапи", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handlePatchMap — редактор полотна.
func (s *Server) handlePatchMap(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("maps:write") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
return
}
var patch store.MapPatch
if !decodeBody(w, r, &patch) {
return
}
mapID := r.PathValue("id")
res, err := s.store.ApplyMapPatch(r.Context(), p.TenantID, mapID, "", &patch)
if err != nil {
s.writeStoreError(w, "збереження мапи", err)
return
}
// Інші відкриті полотна мають дізнатись про зміну: у NOC над однією
// мапою часто працюють кілька людей, і мовчазне розходження станів
// закінчується взаємним затиранням.
s.hub.BroadcastMapUpdate(p.TenantID, mapID, res.Revision)
writeJSON(w, http.StatusOK, res)
}
// handleBuildMap добудовує мапу з виявленої топології.
func (s *Server) handleBuildMap(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("maps:write") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
return
}
mapID := r.PathValue("id")
st, err := s.store.BuildFromTopology(r.Context(), p.TenantID, mapID, "")
if err != nil {
s.writeStoreError(w, "автопобудова мапи", err)
return
}
if st.NodesAdded > 0 || st.EdgesAdded > 0 {
s.hub.BroadcastMapUpdate(p.TenantID, mapID, st.Revision)
}
writeJSON(w, http.StatusOK, st)
}
// handleUndoMap відкочує полотно до попереднього знімка.
func (s *Server) handleUndoMap(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("maps:write") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
return
}
mapID := r.PathValue("id")
res, err := s.store.UndoMap(r.Context(), p.TenantID, mapID, "")
if errors.Is(err, store.ErrNothingToUndo) {
writeError(w, http.StatusConflict, "nothing_to_undo", "історії змін ще немає")
return
}
if err != nil {
s.writeStoreError(w, "відкат мапи", err)
return
}
s.hub.BroadcastMapUpdate(p.TenantID, mapID, res.Revision)
writeJSON(w, http.StatusOK, res)
}
// ---------------------------------------------------------------------
func decodeBody(w http.ResponseWriter, r *http.Request, v any) bool {
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxPatchBody))
// Невідоме поле — майже завжди друкарська помилка в клієнті.
// Мовчки її проковтнути означає, що правка «збереглась», але
// не застосувалась.
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
writeError(w, http.StatusBadRequest, "bad_body", "не вдалося розібрати тіло: "+err.Error())
return false
}
return true
}
func (s *Server) writeStoreError(w http.ResponseWriter, what string, err error) {
switch {
case errors.Is(err, store.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", "не знайдено")
case errors.Is(err, store.ErrConflict):
// 409 із поточною ревізією дозволяє клієнту перечитати мапу
// й повторити правку, а не втратити її.
writeError(w, http.StatusConflict, "revision_conflict", err.Error())
case errors.Is(err, store.ErrPlanLimit):
// 402 — саме те, що сталося: потрібен інший тариф.
writeError(w, http.StatusPaymentRequired, "plan_limit", err.Error())
case errors.Is(err, store.ErrInvalid):
writeError(w, http.StatusBadRequest, "invalid", err.Error())
default:
s.log.Error(what, "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
}
}