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
+1
View File
@@ -69,6 +69,7 @@ func run() error {
return err
}
app := &node.Server{Store: store, Assets: assets, Origin: "http://" + *listen, Version: version, Inventory: func() node.Inventory { return node.Host("/") }}
app.Presentation = node.NewPresentationStore(*dir)
pairing, err := node.OpenPairing(store, *dir, version, app.Inventory)
if err != nil {
return err
@@ -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)
}
@@ -0,0 +1,182 @@
package node
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
const maxPresentationMediaBytes = 256 * 1024 * 1024
var presentationMediaTypes = map[string]string{
".png": "image/png", ".jpg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime",
}
func mediaKindForExtension(extension string) string {
if strings.HasPrefix(presentationMediaTypes[extension], "image/") {
return "image"
}
if strings.HasPrefix(presentationMediaTypes[extension], "video/") {
return "video"
}
return ""
}
func matchesPresentationMedia(head []byte, extension string) bool {
typ := http.DetectContentType(head)
if typ == presentationMediaTypes[extension] {
return true
}
if len(head) >= 12 && string(head[4:8]) == "ftyp" {
brand := string(head[8:12])
switch extension {
case ".avif":
return brand == "avif" || brand == "avis"
case ".mov":
return brand == "qt "
}
}
return extension == ".webm" && bytes.HasPrefix(head, []byte{0x1a, 0x45, 0xdf, 0xa3}) && bytes.Contains(head, []byte("webm"))
}
func (p *PresentationStore) mediaRoutes(mux *http.ServeMux, s *Server) {
mux.HandleFunc("PUT /api/presentation/media/{surface}/{item}", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
if r.PathValue("surface") != "home" || !presentationMediaID.MatchString(r.PathValue("item")) {
reply(w, 400, map[string]string{"error": "Страница или медиаконтент недоступны"})
return
}
name, err := url.PathUnescape(r.Header.Get("X-NODEDC-File-Name"))
if err != nil || !cleanPresentationText(&name, 255, false) || strings.ContainsAny(name, `/\`) {
reply(w, 400, map[string]string{"error": "Некорректное имя файла"})
return
}
extension := strings.ToLower(filepath.Ext(name))
if extension == ".jpeg" {
extension = ".jpg"
}
if presentationMediaTypes[extension] == "" {
reply(w, 415, map[string]string{"error": "Поддерживаются MP4, WebM, MOV, PNG, JPEG, GIF, WebP и AVIF."})
return
}
if r.ContentLength > maxPresentationMediaBytes {
reply(w, 413, map[string]string{"error": "Размер файла не должен превышать 256 МБ."})
return
}
if !p.uploadMu.TryLock() {
reply(w, 409, map[string]string{"error": "Дождитесь завершения текущей загрузки файла."})
return
}
defer p.uploadMu.Unlock()
controller := http.NewResponseController(w)
_ = controller.SetReadDeadline(time.Now().Add(5 * time.Minute))
_ = controller.SetWriteDeadline(time.Now().Add(5 * time.Minute))
dir := filepath.Join(p.dir, "presentation-media")
if os.MkdirAll(dir, 0700) != nil {
reply(w, 500, map[string]string{"error": "Хранилище фонов недоступно"})
return
}
// Local media storage is bounded independently of device recordings.
entries, err := os.ReadDir(dir)
if err != nil {
reply(w, 500, map[string]string{"error": "Хранилище фонов недоступно"})
return
}
var total int64
for _, entry := range entries {
if info, e := entry.Info(); e == nil {
total += info.Size()
}
}
if total > 8*1024*1024*1024-maxPresentationMediaBytes {
reply(w, 413, map[string]string{"error": "Хранилище фонов заполнено. Используйте прямую ссылку на медиаконтент."})
return
}
file, err := os.CreateTemp(dir, ".upload-*")
if err != nil {
reply(w, 500, map[string]string{"error": "Не удалось создать файл фона"})
return
}
defer os.Remove(file.Name())
defer file.Close()
body := http.MaxBytesReader(w, r.Body, maxPresentationMediaBytes)
head := make([]byte, 512)
n, err := io.ReadFull(body, head)
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
reply(w, 400, map[string]string{"error": "Загрузка файла прервана"})
return
}
head = head[:n]
if !matchesPresentationMedia(head, extension) {
reply(w, 415, map[string]string{"error": "Содержимое файла не соответствует поддерживаемому изображению или видео."})
return
}
hash := sha256.New()
written, err := io.Copy(io.MultiWriter(file, hash), io.MultiReader(bytes.NewReader(head), body))
if err != nil {
reply(w, 413, map[string]string{"error": "Загрузка прервана или файл превышает 256 МБ."})
return
}
if err = file.Sync(); err != nil {
reply(w, 500, map[string]string{"error": "Не удалось сохранить файл фона"})
return
}
if err = file.Close(); err != nil {
reply(w, 500, map[string]string{"error": "Не удалось сохранить файл фона"})
return
}
digest := hex.EncodeToString(hash.Sum(nil))
asset := digest + extension
if err = os.Rename(file.Name(), filepath.Join(dir, asset)); err != nil {
reply(w, 500, map[string]string{"error": "Не удалось сохранить файл фона"})
return
}
directory, err := os.Open(dir)
if err != nil {
reply(w, 500, map[string]string{"error": "Не удалось подтвердить сохранение файла"})
return
}
err = directory.Sync()
directory.Close()
if err != nil {
reply(w, 500, map[string]string{"error": "Не удалось подтвердить сохранение файла"})
return
}
reply(w, 200, map[string]any{"url": "/api/presentation/media/" + asset, "fileName": name, "mediaKind": mediaKindForExtension(extension), "sha256": digest, "byteLength": written})
})
mux.HandleFunc("GET /api/presentation/media/{asset}", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
asset := r.PathValue("asset")
if !presentationAssetName.MatchString(asset) {
http.NotFound(w, r)
return
}
path := filepath.Join(p.dir, "presentation-media", asset)
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() {
http.NotFound(w, r)
return
}
file, err := os.Open(path)
if err != nil {
http.NotFound(w, r)
return
}
defer file.Close()
_ = http.NewResponseController(w).SetWriteDeadline(time.Now().Add(5 * time.Minute))
w.Header().Set("Content-Type", presentationMediaTypes[filepath.Ext(asset)])
http.ServeContent(w, r, asset, info.ModTime(), file)
})
}
@@ -0,0 +1,238 @@
package node
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"image"
"image/png"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
func presentationServer(t *testing.T) (*Server, *http.Cookie) {
t.Helper()
s := newTestServer(t)
s.Presentation = NewPresentationStore(t.TempDir())
return s, login(t, s)
}
func presentationJSON(t *testing.T, value PresentationSettings) string {
t.Helper()
b, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func TestPresentationPersistsAndConcurrentWindowsConflict(t *testing.T) {
s, cookie := presentationServer(t)
value := defaultPresentation()
page := value.Pages["home"]
page.Title = " Главная борта "
value.Pages["home"] = page
body := presentationJSON(t, value)
codes := make(chan int, 2)
var wg sync.WaitGroup
for range 2 {
wg.Add(1)
go func() { defer wg.Done(); codes <- call(s, "PUT", "/api/presentation/settings", body, cookie).Code }()
}
wg.Wait()
close(codes)
counts := map[int]int{}
for code := range codes {
counts[code]++
}
if counts[200] != 1 || counts[409] != 1 {
t.Fatal(counts)
}
reopened := NewPresentationStore(s.Presentation.dir)
got, err := reopened.read()
if err != nil || got.Revision != 1 || got.Pages["home"].Title != "Главная борта" {
t.Fatal(got, err)
}
info, err := os.Stat(filepath.Join(reopened.dir, "presentation.json"))
if err != nil || info.Mode().Perm() != 0600 {
t.Fatal(info, err)
}
files, _ := os.ReadDir(reopened.dir)
if len(files) != 1 {
t.Fatal("temporary files survived", files)
}
}
func TestPresentationRejectsInvalidChangesWithoutReplacingSavedState(t *testing.T) {
s, cookie := presentationServer(t)
initial := defaultPresentation()
if w := call(s, "PUT", "/api/presentation/settings", presentationJSON(t, initial), cookie); w.Code != 200 {
t.Fatal(w.Body.String())
}
path := filepath.Join(s.Presentation.dir, "presentation.json")
before, _ := os.ReadFile(path)
for name, mutate := range map[string]func(*PresentationSettings){
"extra page": func(v *PresentationSettings) { v.Pages["system"] = v.Pages["home"] },
"blank title": func(v *PresentationSettings) { p := v.Pages["home"]; p.Title = " "; v.Pages["home"] = p },
"unknown action": func(v *PresentationSettings) {
p := v.Pages["home"]
action := "start-device"
p.PrimaryWorkspaceID = &action
v.Pages["home"] = p
},
"duplicate action": func(v *PresentationSettings) {
p := v.Pages["home"]
p.SecondaryWorkspaceID = p.PrimaryWorkspaceID
v.Pages["home"] = p
},
"script URL": func(v *PresentationSettings) {
p := v.Pages["home"]
p.Background.Items = []PresentationMedia{{ID: "media-test", Source: "url", URL: "javascript:alert(1)", MediaKind: "image"}}
v.Pages["home"] = p
},
"foreign local file": func(v *PresentationSettings) {
p := v.Pages["home"]
p.Background.Items = []PresentationMedia{{ID: "media-test", Source: "file", URL: "/etc/passwd", MediaKind: "image"}}
v.Pages["home"] = p
},
} {
t.Run(name, func(t *testing.T) {
value := defaultPresentation()
value.Revision = 1
mutate(&value)
if w := call(s, "PUT", "/api/presentation/settings", presentationJSON(t, value), cookie); w.Code != 400 {
t.Fatal(w.Code, w.Body.String())
}
after, _ := os.ReadFile(path)
if !bytes.Equal(before, after) {
t.Fatal("saved state changed")
}
})
}
corrupt := []byte(`{"schema":"broken"}`)
if err := os.WriteFile(path, corrupt, 0600); err != nil {
t.Fatal(err)
}
if w := call(s, "GET", "/api/presentation/settings", "", cookie); w.Code != 500 {
t.Fatal(w.Code)
}
if w := call(s, "PUT", "/api/presentation/settings", presentationJSON(t, initial), cookie); w.Code != 500 {
t.Fatal(w.Code)
}
after, _ := os.ReadFile(path)
if !bytes.Equal(corrupt, after) {
t.Fatal("corrupt evidence was replaced")
}
}
func presentationUpload(s *Server, cookie *http.Cookie, name string, data []byte) *httptest.ResponseRecorder {
r := httptest.NewRequest("PUT", s.Origin+"/api/presentation/media/home/media-test", bytes.NewReader(data))
r.Header.Set("Origin", s.Origin)
r.Header.Set("X-NODEDC-File-Name", name)
r.Header.Set("Content-Type", "application/octet-stream")
if cookie != nil {
r.AddCookie(cookie)
}
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, r)
return w
}
func TestPresentationUploadSurvivesReopenAndSupportsRange(t *testing.T) {
s, cookie := presentationServer(t)
var data bytes.Buffer
if err := png.Encode(&data, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
t.Fatal(err)
}
w := presentationUpload(s, cookie, "background.png", data.Bytes())
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
var asset struct {
URL, FileName, MediaKind string
ByteLength int64
SHA256 string
}
if err := json.Unmarshal(w.Body.Bytes(), &asset); err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(data.Bytes())
if asset.SHA256 != hex.EncodeToString(digest[:]) || asset.ByteLength != int64(data.Len()) || asset.MediaKind != "image" {
t.Fatal(asset)
}
value := defaultPresentation()
page := value.Pages["home"]
page.Background.Enabled = true
page.Background.Items = []PresentationMedia{{ID: "media-test", Source: "file", URL: asset.URL, FileName: &asset.FileName, MediaKind: asset.MediaKind}}
value.Pages["home"] = page
if w := call(s, "PUT", "/api/presentation/settings", presentationJSON(t, value), cookie); w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
s.Presentation = NewPresentationStore(s.Presentation.dir)
if got, err := s.Presentation.read(); err != nil || got.Pages["home"].Background.Items[0].URL != asset.URL {
t.Fatal(got, err)
}
r := httptest.NewRequest("GET", s.Origin+asset.URL, nil)
r.AddCookie(cookie)
r.Header.Set("Range", "bytes=0-7")
w = httptest.NewRecorder()
s.Handler().ServeHTTP(w, r)
if w.Code != 206 || !bytes.Equal(w.Body.Bytes(), data.Bytes()[:8]) || w.Header().Get("Content-Type") != "image/png" {
t.Fatal(w.Code, w.Header(), w.Body.String())
}
if w := call(s, "GET", asset.URL, "", nil); w.Code != 401 {
t.Fatal("anonymous media access", w.Code)
}
if w := presentationUpload(s, cookie, "fake.png", []byte("<html>not an image</html>")); w.Code != 415 {
t.Fatal(w.Code)
}
files, _ := os.ReadDir(filepath.Join(s.Presentation.dir, "presentation-media"))
if len(files) != 1 {
t.Fatal("rejected upload left files", files)
}
}
func TestPresentationRequiresSessionAndSameOrigin(t *testing.T) {
s, cookie := presentationServer(t)
for _, method := range []string{"GET", "PUT"} {
if w := call(s, method, "/api/presentation/settings", presentationJSON(t, defaultPresentation()), nil); w.Code != 401 {
t.Fatal(method, w.Code)
}
}
if w := presentationUpload(s, nil, "x.png", nil); w.Code != 401 {
t.Fatal(w.Code)
}
r := httptest.NewRequest("PUT", s.Origin+"/api/presentation/settings", strings.NewReader(presentationJSON(t, defaultPresentation())))
r.AddCookie(cookie)
r.Header.Set("Origin", "https://unrelated.invalid")
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, r)
if w.Code != 403 {
t.Fatal(w.Code)
}
}
func TestLocalViewerCSPAdmitsOnlyItsOwnRuntimeFrame(t *testing.T) {
s := newTestServer(t)
for _, path := range []string{"/", "/rerun-runtime.html", "/assets/runtime.js"} {
w := call(s, "GET", path, "", nil)
csp := w.Header().Get("Content-Security-Policy")
if path == "/rerun-runtime.html" {
if !strings.Contains(csp, "frame-ancestors 'self'") || !strings.Contains(csp, "'wasm-unsafe-eval'") {
t.Fatal(path, csp)
}
} else if !strings.Contains(csp, "frame-ancestors 'none'") || strings.Contains(csp, "'wasm-unsafe-eval'") {
t.Fatal(path, csp)
}
if strings.Contains(csp, "'unsafe-eval'") || strings.Contains(csp, "script-src *") {
t.Fatal(csp)
}
}
}
+9 -1
View File
@@ -24,6 +24,7 @@ type Server struct {
Access *AccessStore
Tailscale func() TailscaleStatus
Environment func() EnvironmentStatus
Presentation *PresentationStore
mu sync.Mutex
logins map[string]time.Time
sessions map[string]time.Time
@@ -80,6 +81,9 @@ func reply(w http.ResponseWriter, status int, v any) {
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
if s.Presentation != nil {
s.presentationRoutes(mux)
}
if s.Sensors != nil {
s.Sensors.Routes(mux, s)
}
@@ -165,7 +169,11 @@ func (s *Server) Handler() http.Handler {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
ancestor, scripts := "'none'", "'self'"
if r.URL.Path == "/rerun-runtime.html" {
ancestor, scripts = "'self'", "'self' 'wasm-unsafe-eval'"
}
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src "+scripts+"; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob: https: http:; connect-src 'self'; frame-src 'self'; worker-src 'self' blob:; frame-ancestors "+ancestor+"; base-uri 'none'; form-action 'self'")
if "http://"+r.Host != s.Origin {
http.Error(w, "Invalid host", http.StatusForbidden)
return
+3 -1
View File
@@ -11,13 +11,15 @@ import sys
from build_deb import build, VERSION, BRAND_SHA256
ROOT = Path(__file__).resolve().parents[1]
DG_COMMIT = "26a1bf72a2a32b002e51f910e8faa300333bb6c3"
DG_COMMIT = "b10fd5d645ddfb8c373ae6105efa0850aef2509c"
def guideline_sources():
dg = ROOT.parents[2] / "NODEDC_DESIGN_GUIDELINE"
paths = list((dg / "packages/ui-react/src").glob("*"))
paths += list((dg / "packages/ui-react/dist").glob("*"))
paths += list((dg / "packages/ui-core/src").glob("*"))
paths += list((dg / "packages/ui-core/dist").glob("*"))
paths += [dg / "packages/ui-core/styles.css", dg / "packages/tokens/tokens.css", dg / "packages/tokens/themes.css"]
return {str(p.relative_to(dg)): hashlib.sha256(p.read_bytes()).hexdigest()
for p in sorted(paths) if p.is_file()}
+1 -1
View File
@@ -11,7 +11,7 @@ import sys
ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.8.13"
VERSION = "0.8.14"
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
from debian import package
+22
View File
@@ -0,0 +1,22 @@
import { EnvironmentSettingsWindow, LandingStage } from "@nodedc/ui-react";
import type { EnvironmentPage } from "@nodedc/ui-core";
import { views, type ViewId } from "./nodeModel";
import type { usePresentation } from "./usePresentation";
const surfaces = [{ id: "home", home: true, description: "Главная страница продукта", actions: views }];
export function Home({ page, openView }: { page: EnvironmentPage; openView: (id: ViewId) => void }) {
const actions = [page.primaryWorkspaceId, page.secondaryWorkspaceId].flatMap(id => {
const view = views.find(item => item.id === id);
return view ? [{ id: view.id, label: view.label, icon: view.icon, onSelect: () => openView(view.id) }] : [];
});
return <LandingStage page={page} actions={actions} />;
}
export function HomeSettings({ open, onClose, presentation }: {
open: boolean; onClose: () => void; presentation: ReturnType<typeof usePresentation>;
}) {
return <EnvironmentSettingsWindow productName="Mission Core Node" open={open} onClose={onClose}
surfaces={surfaces} settings={presentation.settings} state={presentation.state} error={presentation.error}
onSave={presentation.save} onUpload={presentation.upload} />;
}
+14 -9
View File
@@ -16,19 +16,23 @@ import { EnvironmentView } from "./EnvironmentView";
import { CoreConnectionView } from "./CoreConnectionView";
import "./node.css";
import { NodeSensors } from "./NodeSensors";
import { Home, HomeSettings } from "./Home";
import { usePresentation } from "./usePresentation";
function App() {
const node = useNode();
const { value, pending, locked, refresh, failure } = node;
const environment = useEnvironment(!!value, failure);
const presentation = usePresentation(!!value);
const [settingsOpen, setSettingsOpen] = useState(false);
const refreshAll = () => {if(!environment.running) {void refresh();void environment.refresh();}};
const [root, setRoot] = useState<RootId>("system");
const workspace = useApplicationWorkspace<ViewId>({ activeView: "environment" });
const [root, setRoot] = useState<RootId | null>(null);
const workspace = useApplicationWorkspace<ViewId>();
const [adding, setAdding] = useState(false);
const [theme, setTheme] = useState(() => localStorage.getItem("node-theme") === "light" ? "light" : "dark");
// Theme applies to body portals as well as the application shell.
useEffect(() => { document.documentElement.dataset.nodedcTheme = theme; }, [theme]);
const currentRoot = roots.find(item => item.id === root)!;
const currentRoot = roots.find(item => item.id === root) ?? roots[0];
const currentView = views.find(item => item.id === workspace.activeView);
function openView(id: ViewId) { if(environment.running) return; setAdding(false); setRoot(views.find(item => item.id === id)!.root); workspace.openView(id); }
function selectRoot(id: RootId) { const first = roots.find(item => item.id === id)!.first; if (first) openView(first); }
@@ -41,22 +45,23 @@ function App() {
: workspace.activeView === "tailscale" ? <TailnetAccess failure={failure} revision={value.host.collected_at} />
: workspace.activeView === "ssh" ? <SystemAccess revision={value.host.collected_at} failure={failure} success={node.success} adding={adding} closeAdd={() => setAdding(false)} /> : null;
return <>
<ApplicationShell data-nodedc-ui className="node-app" header={<AppHeader brandMonochrome brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />} brandLabel="Mission Core Node"
center={<HeaderNavigation label="Разделы бортового компьютера" value={root} items={roots.map(item => ({ value: item.id, label: item.label, disabled: !value || !item.first || environment.running }))} onChange={selectRoot} />}
right={<HeaderProfile><UserProfileMenu displayName="Node" subtitle={value?.name ?? "Mission Core Node"} triggerLabel={null} actions={[{ id: "refresh", label: "Обновить сведения", icon: "refresh", onSelect: refreshAll }, { id: "theme", label: theme === "dark" ? "Светлая тема" : "Тёмная тема", icon: "eye", onSelect: () => { const next = theme === "dark" ? "light" : "dark"; setTheme(next); localStorage.setItem("node-theme", next); } }]} /></HeaderProfile>} />}
<ApplicationShell data-nodedc-ui className="node-app" header={<AppHeader brandMonochrome brandHref="/" brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />} brandLabel={presentation.settings.pages.home.headerLabel}
center={<HeaderNavigation label="Разделы бортового компьютера" value={root ?? undefined} items={roots.map(item => ({ value: item.id, label: item.label, disabled: !value || !item.first || environment.running }))} onChange={selectRoot} />}
right={<HeaderProfile><UserProfileMenu displayName="Node" subtitle={value?.name ?? "Mission Core Node"} triggerLabel={null} actions={[{ id: "settings", label: "Настройки", icon: "settings", onSelect: () => { if (value) { void presentation.refresh(); setSettingsOpen(true); } } }, { id: "refresh", label: "Обновить сведения", icon: "refresh", onSelect: refreshAll }, { id: "theme", label: theme === "dark" ? "Светлая тема" : "Тёмная тема", icon: "eye", onSelect: () => { const next = theme === "dark" ? "light" : "dark"; setTheme(next); localStorage.setItem("node-theme", next); } }]} /></HeaderProfile>} />}
navigationOpen={!!value && workspace.navigationOpen} contentOpen={!!value && workspace.contentOpen} contentExpanded={workspace.contentExpanded}
navigation={<AdminNavigationPanel eyebrow="MISSION CORE NODE" title={currentRoot.label} onClose={workspace.closeNavigation} closeLabel="Закрыть навигацию" navigationLabel="Разделы выбранной вкладки"
contexts={value ? [{ id: "board", label: value.name, description: value.host.hostname, icon: <Icon name="activity" />, onSelect: () => openView("overview") }] : []}
items={views.filter(item => item.root === root).map(item => ({ id: item.id, label: item.label, icon: <Icon name={item.icon} /> }))} activeId={workspace.activeView ?? undefined} onItemChange={id => openView(id as ViewId)} footer={<span>Mission Core Node · {value?.version}</span>} />}
content={currentView && <ApplicationPanel title={currentView.label} eyebrow={currentRoot.label} expanded={workspace.contentExpanded} onExpandedChange={workspace.setContentExpanded} onClose={workspace.closeView}
utilityActions={[...(workspace.activeView === "ssh" ? [{ label: "Добавить доверенное устройство", icon: "plus" as const, onClick: () => setAdding(true) }] : []), { label: "Обновить сведения", icon: "refresh", disabled: pending || environment.running, onClick: refreshAll }]}>{content}</ApplicationPanel>}
stage={<div className="node-stage" aria-busy={pending}>
{value ? <SettingsCard title={value.name} eyebrow="MISSION CORE NODE" description={`Бортовой компьютер · ${value.host.architecture}`}><div className="node-home-actions">{roots.map(item => <Button key={item.id} disabled={!item.first} onClick={() => selectRoot(item.id)}>{item.label}</Button>)}</div></SettingsCard> : <SettingsCard className="node-entry" title={pending ? "Подключаемся к ноде" : locked ? "Вход в Mission Core Node" : "Нода недоступна"}>
stage={value ? <Home page={presentation.settings.pages.home} openView={openView} /> : <div className="node-stage" aria-busy={pending}>
<SettingsCard className="node-entry" title={pending ? "Подключаемся к ноде" : locked ? "Вход в Mission Core Node" : "Нода недоступна"}>
{pending ? <ActivityIndicator label="Получение сведений о ноде" /> : <p className="node-note">{locked ? "Подтвердите доступ в системном окне." : "Не удалось связаться со службой. Повторите подключение."}</p>}
<Button disabled={pending} onClick={() => { if (locked) { if (!desktopLogin()) failure(new Error("Откройте установленное приложение Mission Core Node из меню приложений.")); } else void refresh(); }}>{locked ? "Войти" : "Повторить подключение"}</Button>
</SettingsCard>}
</SettingsCard>
</div>} />
<ToastStack items={node.toasts} onDismiss={node.dismiss} />
<HomeSettings open={!!value && settingsOpen} onClose={() => setSettingsOpen(false)} presentation={presentation} />
</>;
}
createRoot(document.getElementById("root")!).render(<App />);
-1
View File
@@ -11,4 +11,3 @@ body { margin: 0; background: var(--nodedc-canvas); color: var(--nodedc-text-pri
.node-form > button { justify-self: start; }
.node-note { margin: 0; color: var(--nodedc-text-secondary); font-size: var(--nodedc-font-size-sm); line-height: 1.6; overflow-wrap: anywhere; }
.node-entry { max-width: 640px; margin: var(--nodedc-space-8) auto; }
.node-home-actions { display: flex; flex-wrap: wrap; gap: var(--nodedc-space-3); }
+57
View File
@@ -0,0 +1,57 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { EnvironmentSettings, UploadedEnvironmentMedia } from "@nodedc/ui-core";
import { APIError, request } from "./api";
export function defaultPresentation(): EnvironmentSettings {
return { revision: 0, pages: { home: {
headerLabel: "Mission Core Node", eyebrow: "NODEDC / MISSION CORE NODE", title: "Mission Core Node",
description: "Подключение устройств, запись и просмотр данных на бортовом компьютере.",
primaryWorkspaceId: "sensors", secondaryWorkspaceId: "environment",
background: { enabled: false, imageDurationSeconds: 10, items: [] },
} } };
}
export function usePresentation(authorized: boolean) {
const [settings, setSettings] = useState(defaultPresentation);
const [state, setState] = useState<"loading" | "ready" | "saving" | "error">("loading");
const [error, setError] = useState<string | null>(null);
const epoch = useRef(0);
const refresh = useCallback(async () => {
if (!authorized) return;
const generation = ++epoch.current;
setState("loading");
try {
const next = await request<EnvironmentSettings & { schema: string }>("/api/presentation/settings");
if (generation !== epoch.current) return;
if (next.schema !== "missioncore.node.presentation/v1" || !next.pages.home || Object.keys(next.pages).length !== 1) throw new Error("Версия оформления главной не поддерживается.");
setSettings(next); setError(null); setState("ready");
} catch (reason) {
if (generation !== epoch.current) return;
setError(reason instanceof Error ? reason.message : "Не удалось загрузить оформление главной."); setState("error");
}
}, [authorized]);
useEffect(() => { void refresh(); return () => { epoch.current += 1; }; }, [refresh]);
const save = useCallback(async (draft: EnvironmentSettings) => {
setState("saving"); setError(null);
try {
const next = await request<EnvironmentSettings>("/api/presentation/settings", "PUT", draft);
setSettings(next); setState("ready"); return next;
} catch (reason) {
const message = reason instanceof Error ? reason.message : "Не удалось сохранить оформление главной.";
setError(message); setState("error"); throw new Error(message);
}
}, []);
const upload = useCallback(async (surfaceId: string, itemId: string, file: File): Promise<UploadedEnvironmentMedia> => {
if (surfaceId !== "home") throw new Error("Оформление доступно только для главной страницы.");
const response = await fetch(`/api/presentation/media/home/${encodeURIComponent(itemId)}`, {
method: "PUT", credentials: "same-origin", signal: AbortSignal.timeout(300000),
headers: { "Content-Type": file.type || "application/octet-stream", "X-NODEDC-File-Name": encodeURIComponent(file.name) }, body: file,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new APIError(body.error ?? "Не удалось загрузить фон главной страницы.", response.status);
}
return response.json();
}, []);
return { settings, state, error, refresh, save, upload };
}