238 lines
8.2 KiB
Go
238 lines
8.2 KiB
Go
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)
|
|
}
|