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