Reuse canonical home and settings on Node and admit local K1 viewer

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 22:55:13 +03:00
parent 1832943558
commit a23c5b2005
23 changed files with 891 additions and 1139 deletions
@@ -0,0 +1,237 @@
package node
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"unicode"
"unicode/utf8"
)
const presentationSchema = "missioncore.node.presentation/v1"
type PresentationMedia struct {
ID string `json:"id"`
Source string `json:"source"`
URL string `json:"url"`
MediaKind string `json:"mediaKind"`
FileName *string `json:"fileName"`
}
type PresentationBackground struct {
Enabled bool `json:"enabled"`
ImageDurationSeconds int `json:"imageDurationSeconds"`
Items []PresentationMedia `json:"items"`
}
type PresentationPage struct {
HeaderLabel string `json:"headerLabel"`
Eyebrow string `json:"eyebrow"`
Title string `json:"title"`
Description string `json:"description"`
PrimaryWorkspaceID *string `json:"primaryWorkspaceId"`
SecondaryWorkspaceID *string `json:"secondaryWorkspaceId"`
Background PresentationBackground `json:"background"`
}
type PresentationSettings struct {
Schema string `json:"schema"`
Revision int64 `json:"revision"`
Pages map[string]PresentationPage `json:"pages"`
}
// Presentation is local product state, separate from identity and device authority.
type PresentationStore struct {
mu sync.Mutex
dir string
uploadMu sync.Mutex
}
func NewPresentationStore(dir string) *PresentationStore { return &PresentationStore{dir: dir} }
func defaultPresentation() PresentationSettings {
primary, secondary := "sensors", "environment"
return PresentationSettings{Schema: presentationSchema, Pages: map[string]PresentationPage{"home": {
HeaderLabel: "Mission Core Node", Eyebrow: "NODEDC / MISSION CORE NODE", Title: "Mission Core Node",
Description: "Подключение устройств, запись и просмотр данных на бортовом компьютере.",
PrimaryWorkspaceID: &primary, SecondaryWorkspaceID: &secondary,
Background: PresentationBackground{ImageDurationSeconds: 10, Items: []PresentationMedia{}},
}}}
}
func (p *PresentationStore) read() (PresentationSettings, error) {
file, err := os.Open(filepath.Join(p.dir, "presentation.json"))
if errors.Is(err, os.ErrNotExist) {
return defaultPresentation(), nil
}
if err != nil {
return PresentationSettings{}, err
}
defer file.Close()
var value PresentationSettings
decoder := json.NewDecoder(io.LimitReader(file, 128*1024+1))
decoder.DisallowUnknownFields()
if err = decoder.Decode(&value); err != nil {
return value, err
}
if decoder.Decode(new(any)) != io.EOF || value.Schema != presentationSchema {
return value, errors.New("invalid presentation document")
}
return value, p.validate(&value)
}
var presentationMediaID = regexp.MustCompile(`^media-[a-z0-9-]{1,58}$`)
var presentationAssetName = regexp.MustCompile(`^[a-f0-9]{64}\.(png|jpg|gif|webp|avif|mp4|webm|mov)$`)
func cleanPresentationText(value *string, limit int, multiline bool) bool {
*value = strings.TrimSpace(*value)
return *value != "" && utf8.ValidString(*value) && utf8.RuneCountInString(*value) <= limit &&
!strings.ContainsFunc(*value, func(r rune) bool { return unicode.IsControl(r) && !(multiline && (r == '\n' || r == '\t')) })
}
func (p *PresentationStore) validate(value *PresentationSettings) error {
invalid := errors.New("Проверьте текст, быстрые переходы и медиаконтент главной страницы.")
page, ok := value.Pages["home"]
if !ok || len(value.Pages) != 1 || value.Revision < 0 || value.Revision >= 1<<53-1 ||
!cleanPresentationText(&page.HeaderLabel, 40, false) || !cleanPresentationText(&page.Eyebrow, 80, false) ||
!cleanPresentationText(&page.Title, 120, false) || !cleanPresentationText(&page.Description, 500, true) {
return invalid
}
actions := map[string]bool{"sensors": true, "environment": true, "overview": true, "network": true, "diagnostics": true, "usb": true, "tailscale": true, "ssh": true, "core": true}
for _, action := range []*string{page.PrimaryWorkspaceID, page.SecondaryWorkspaceID} {
if action != nil && !actions[*action] {
return invalid
}
}
if page.PrimaryWorkspaceID != nil && page.SecondaryWorkspaceID != nil && *page.PrimaryWorkspaceID == *page.SecondaryWorkspaceID {
return invalid
}
bg := page.Background
if bg.ImageDurationSeconds < 1 || bg.ImageDurationSeconds > 60 || bg.Items == nil || len(bg.Items) > 24 || (bg.Enabled && len(bg.Items) == 0) {
return invalid
}
ids := map[string]bool{}
for _, item := range bg.Items {
if !presentationMediaID.MatchString(item.ID) || ids[item.ID] || len(item.URL) > 2048 ||
(item.MediaKind != "image" && item.MediaKind != "video") {
return invalid
}
ids[item.ID] = true
if item.FileName != nil && !cleanPresentationText(item.FileName, 255, false) {
return invalid
}
switch item.Source {
case "url":
u, err := url.Parse(item.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil {
return invalid
}
case "file":
name := strings.TrimPrefix(item.URL, "/api/presentation/media/")
if item.URL != "/api/presentation/media/"+name || !presentationAssetName.MatchString(name) {
return invalid
}
info, err := os.Lstat(filepath.Join(p.dir, "presentation-media", name))
if err != nil || !info.Mode().IsRegular() || mediaKindForExtension(filepath.Ext(name)) != item.MediaKind {
return invalid
}
default:
return invalid
}
}
value.Pages["home"] = page
return nil
}
func (p *PresentationStore) save(value PresentationSettings) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
file, err := os.CreateTemp(p.dir, ".presentation-*")
if err != nil {
return err
}
defer os.Remove(file.Name())
if _, err = file.Write(data); err == nil {
err = file.Sync()
}
closeErr := file.Close()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
if err = os.Rename(file.Name(), filepath.Join(p.dir, "presentation.json")); err != nil {
return err
}
dir, err := os.Open(p.dir)
if err != nil {
return err
}
defer dir.Close()
return dir.Sync()
}
func (s *Server) presentationRoutes(mux *http.ServeMux) {
p := s.Presentation
mux.HandleFunc("GET /api/presentation/settings", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
p.mu.Lock()
defer p.mu.Unlock()
value, err := p.read()
if err != nil {
reply(w, 500, map[string]string{"error": "Не удалось прочитать оформление главной. Сохранённые настройки не изменены."})
return
}
reply(w, 200, value)
})
mux.HandleFunc("PUT /api/presentation/settings", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
if r.Header.Get("Content-Type") != "application/json" {
reply(w, 415, map[string]string{"error": "Ожидался JSON"})
return
}
var value PresentationSettings
d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128*1024))
d.DisallowUnknownFields()
if d.Decode(&value) != nil || d.Decode(new(any)) != io.EOF || (value.Schema != "" && value.Schema != presentationSchema) {
reply(w, 400, map[string]string{"error": "Некорректные настройки оформления"})
return
}
p.mu.Lock()
defer p.mu.Unlock()
current, err := p.read()
if err != nil {
reply(w, 500, map[string]string{"error": "Не удалось прочитать сохранённые настройки"})
return
}
if value.Revision != current.Revision {
reply(w, 409, map[string]string{"error": "Настройки изменены в другом окне. Обновите их перед сохранением."})
return
}
if err = p.validate(&value); err != nil {
reply(w, 400, map[string]string{"error": err.Error()})
return
}
value.Schema, value.Revision = presentationSchema, current.Revision+1
if err = p.save(value); err != nil {
reply(w, 500, map[string]string{"error": "Не удалось сохранить оформление главной"})
return
}
reply(w, 200, value)
})
p.mediaRoutes(mux, s)
}