Редагування вмикається кнопкою. Полотно, яке рухається від випадкового кліку, — найшвидший спосіб зіпсувати схему, на яку дивиться черговий. Доступи за групами користувачів (topo.map_permissions): read, write або deny, deny перекриває решту. Мапа без жодного запису доступна всім, хто має maps:read — інакше кожна нова мапа була б невидимою до окремого налаштування. Перевірено: обмежена мапа зникає зі списку, прямий GET дає 403, PATCH у read-only мапу теж 403. Виділення рамкою лівою кнопкою (панорамує середня або права), Ctrl і Shift додають до виділення, груповий драг пише один патч замість десяти ревізій. Вигляд застосовується до всіх виділених одразу. Додавання хостів на мапу з пошуком і фільтром за групою; ті, що вже на полотні, показані сірим. Нові розкладаються сіткою праворуч від наявних. Власні іконки (topo.icons): SVG/PNG/JPEG/WebP до 256 КБ у БД, а не у файловій системі — інакше інсталяція з кількох процесів вимагала б спільного тому, а бекап продукту перестав би бути бекапом бази. SVG віддається з CSP і nosniff: інакше завантажена іконка стає збереженою XSS проти власного ж інтерфейсу. Форми вузла: картка, пігулка, крапка, картинка, без рамки. Підпис праворуч, знизу, зверху або відсутній. У форми «картинка» стан показує обвідка знизу, а не рамка навколо — рамка зʼїдає саму картинку. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
176 lines
6.4 KiB
Go
176 lines
6.4 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"github.com/netpulse/netpulse/server/internal/store"
|
||
)
|
||
|
||
// maxIconBytes — стеля розміру іконки.
|
||
//
|
||
// Іконка вузла — це 32–64 пікселі на екрані. Мегабайтний PNG там не дає
|
||
// нічого, крім повільного полотна на мапі з сотнею вузлів.
|
||
const maxIconBytes = 256 * 1024
|
||
|
||
var validIconMimes = map[string]bool{
|
||
"image/svg+xml": true, "image/png": true,
|
||
"image/jpeg": true, "image/webp": true,
|
||
}
|
||
|
||
func (s *Server) handleListMapPermissions(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "maps:read") {
|
||
return
|
||
}
|
||
list, err := s.store.ListMapPermissions(r.Context(), p.TenantID, r.PathValue("id"))
|
||
if err != nil {
|
||
s.writeStoreError(w, "доступи до мапи", err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"permissions": list})
|
||
}
|
||
|
||
func (s *Server) handleSetMapPermissions(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "maps:write") {
|
||
return
|
||
}
|
||
var in struct {
|
||
Permissions []store.MapPermission `json:"permissions"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
|
||
return
|
||
}
|
||
for _, perm := range in.Permissions {
|
||
if perm.Level != "read" && perm.Level != "write" && perm.Level != "deny" {
|
||
writeError(w, http.StatusBadRequest, "bad_level",
|
||
"рівень доступу: read, write або deny")
|
||
return
|
||
}
|
||
}
|
||
if err := s.store.SetMapPermissions(r.Context(), p.TenantID, r.PathValue("id"), in.Permissions); err != nil {
|
||
s.writeStoreError(w, "доступи до мапи", err)
|
||
return
|
||
}
|
||
list, err := s.store.ListMapPermissions(r.Context(), p.TenantID, r.PathValue("id"))
|
||
if err != nil {
|
||
s.writeStoreError(w, "доступи до мапи", err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"permissions": list})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Іконки
|
||
// ---------------------------------------------------------------------
|
||
|
||
func (s *Server) handleListIcons(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "maps:read") {
|
||
return
|
||
}
|
||
list, err := s.store.ListIcons(r.Context(), p.TenantID)
|
||
if err != nil {
|
||
s.writeStoreError(w, "іконки", err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"icons": list})
|
||
}
|
||
|
||
// handleGetIcon віддає саму картинку.
|
||
//
|
||
// Окремим запитом, а не в JSON поруч зі списком: браузер кешує
|
||
// зображення за URL, і мапа з сотнею вузлів на десяти іконках зробить
|
||
// десять запитів один раз, а не потягне base64 при кожному оновленні
|
||
// списку.
|
||
func (s *Server) handleGetIcon(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "maps:read") {
|
||
return
|
||
}
|
||
data, mime, err := s.store.IconBytes(r.Context(), p.TenantID, r.PathValue("id"))
|
||
if err != nil {
|
||
s.writeStoreError(w, "іконка", err)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", mime)
|
||
// Вміст іконки незмінний: id новий на кожне завантаження.
|
||
w.Header().Set("Cache-Control", "private, max-age=86400, immutable")
|
||
// SVG виконує скрипти, якщо його відкрити напряму. Заголовок робить
|
||
// із нього просто картинку — інакше завантажена іконка стає
|
||
// збереженою XSS проти власного ж інтерфейсу.
|
||
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
|
||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||
_, _ = w.Write(data)
|
||
}
|
||
|
||
func (s *Server) handleCreateIcon(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "maps:write") {
|
||
return
|
||
}
|
||
|
||
var in struct {
|
||
Name string `json:"name"`
|
||
Mime string `json:"mime"`
|
||
// Картинка приходить base64: JSON бінарні дані не носить, а
|
||
// заводити multipart заради одного поля означало б два різні
|
||
// способи говорити з тим самим API.
|
||
Data string `json:"data"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
|
||
return
|
||
}
|
||
|
||
in.Name = strings.TrimSpace(in.Name)
|
||
if in.Name == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "потрібна назва іконки")
|
||
return
|
||
}
|
||
if !validIconMimes[in.Mime] {
|
||
writeError(w, http.StatusBadRequest, "bad_mime", "підтримуються SVG, PNG, JPEG і WebP")
|
||
return
|
||
}
|
||
|
||
// Data URI приходить як «data:image/png;base64,….» — відрізаємо
|
||
// префікс, бо саме в такому вигляді його віддає FileReader.
|
||
if i := strings.Index(in.Data, ","); i >= 0 && strings.HasPrefix(in.Data, "data:") {
|
||
in.Data = in.Data[i+1:]
|
||
}
|
||
data, err := base64.StdEncoding.DecodeString(in.Data)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_data", "картинка не читається як base64")
|
||
return
|
||
}
|
||
if len(data) == 0 {
|
||
writeError(w, http.StatusBadRequest, "bad_data", "порожня картинка")
|
||
return
|
||
}
|
||
if len(data) > maxIconBytes {
|
||
writeError(w, http.StatusRequestEntityTooLarge, "too_big",
|
||
"іконка більша за 256 КБ — на мапі це лише повільне полотно")
|
||
return
|
||
}
|
||
|
||
id, err := s.store.CreateIcon(r.Context(), p.TenantID, p.UserID, in.Name, in.Mime, data)
|
||
if err != nil {
|
||
if isUniqueViolation(err) {
|
||
writeError(w, http.StatusConflict, "duplicate", "іконка з такою назвою вже є")
|
||
return
|
||
}
|
||
s.writeStoreError(w, "збереження іконки", err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
|
||
}
|
||
|
||
func (s *Server) handleDeleteIcon(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "maps:write") {
|
||
return
|
||
}
|
||
if err := s.store.DeleteIcon(r.Context(), p.TenantID, r.PathValue("id")); err != nil {
|
||
s.writeStoreError(w, "видалення іконки", err)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|