Reuse canonical home and settings on Node and admit local K1 viewer
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user