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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user