Merge remote-tracking branch 'origin/260201-readonly' into beta
# Conflicts: # README.md # www/index.html
This commit is contained in:
@@ -462,6 +462,8 @@ api:
|
|||||||
allow_paths: [/api, /api/streams, /api/webrtc, /api/frame.jpeg]
|
allow_paths: [/api, /api/streams, /api/webrtc, /api/frame.jpeg]
|
||||||
# enable auth for localhost (used together with username and password)
|
# enable auth for localhost (used together with username and password)
|
||||||
local_auth: true
|
local_auth: true
|
||||||
|
# disable write actions in WebUI/API
|
||||||
|
read_only: true
|
||||||
|
|
||||||
exec:
|
exec:
|
||||||
# use only allowed exec paths
|
# use only allowed exec paths
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ func Init() {
|
|||||||
TLSCert string `yaml:"tls_cert"`
|
TLSCert string `yaml:"tls_cert"`
|
||||||
TLSKey string `yaml:"tls_key"`
|
TLSKey string `yaml:"tls_key"`
|
||||||
UnixListen string `yaml:"unix_listen"`
|
UnixListen string `yaml:"unix_listen"`
|
||||||
|
ReadOnly bool `yaml:"read_only"`
|
||||||
|
|
||||||
AllowPaths []string `yaml:"allow_paths"`
|
AllowPaths []string `yaml:"allow_paths"`
|
||||||
} `yaml:"api"`
|
} `yaml:"api"`
|
||||||
@@ -50,6 +51,9 @@ func Init() {
|
|||||||
allowPaths = cfg.Mod.AllowPaths
|
allowPaths = cfg.Mod.AllowPaths
|
||||||
basePath = cfg.Mod.BasePath
|
basePath = cfg.Mod.BasePath
|
||||||
log = app.GetLogger("api")
|
log = app.GetLogger("api")
|
||||||
|
ReadOnly = cfg.Mod.ReadOnly
|
||||||
|
app.ConfigReadOnly = ReadOnly
|
||||||
|
app.Info["read_only"] = ReadOnly
|
||||||
|
|
||||||
initStatic(cfg.Mod.StaticDir)
|
initStatic(cfg.Mod.StaticDir)
|
||||||
|
|
||||||
@@ -149,6 +153,15 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var Handler http.Handler
|
var Handler http.Handler
|
||||||
|
var ReadOnly bool
|
||||||
|
|
||||||
|
func IsReadOnly() bool {
|
||||||
|
return ReadOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadOnlyError(w http.ResponseWriter) {
|
||||||
|
http.Error(w, "read-only", http.StatusForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
// HandleFunc handle pattern with relative path:
|
// HandleFunc handle pattern with relative path:
|
||||||
// - "api/streams" => "{basepath}/api/streams"
|
// - "api/streams" => "{basepath}/api/streams"
|
||||||
@@ -251,6 +264,11 @@ func exitHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if IsReadOnly() {
|
||||||
|
ReadOnlyError(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
s := r.URL.Query().Get("code")
|
s := r.URL.Query().Get("code")
|
||||||
code, err := strconv.Atoi(s)
|
code, err := strconv.Atoi(s)
|
||||||
|
|
||||||
@@ -269,6 +287,11 @@ func restartHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if IsReadOnly() {
|
||||||
|
ReadOnlyError(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
path, err := os.Executable()
|
path, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
@@ -287,6 +310,10 @@ func logHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Type", "application/jsonlines")
|
w.Header().Set("Content-Type", "application/jsonlines")
|
||||||
_, _ = app.MemoryLog.WriteTo(w)
|
_, _ = app.MemoryLog.WriteTo(w)
|
||||||
case "DELETE":
|
case "DELETE":
|
||||||
|
if IsReadOnly() {
|
||||||
|
ReadOnlyError(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
app.MemoryLog.Reset()
|
app.MemoryLog.Reset()
|
||||||
Response(w, "OK", "text/plain")
|
Response(w, "OK", "text/plain")
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ func configHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
Response(w, data, "application/yaml")
|
Response(w, data, "application/yaml")
|
||||||
|
|
||||||
case "POST", "PATCH":
|
case "POST", "PATCH":
|
||||||
|
if IsReadOnly() {
|
||||||
|
ReadOnlyError(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
data, err := io.ReadAll(r.Body)
|
data, err := io.ReadAll(r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AlexxIT/go2rtc/internal/app"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfigHandlerReadOnly(t *testing.T) {
|
||||||
|
prevPath := app.ConfigPath
|
||||||
|
prevReadOnly := ReadOnly
|
||||||
|
t.Cleanup(func() {
|
||||||
|
app.ConfigPath = prevPath
|
||||||
|
ReadOnly = prevReadOnly
|
||||||
|
})
|
||||||
|
|
||||||
|
app.ConfigPath = filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
ReadOnly = true
|
||||||
|
|
||||||
|
for _, method := range []string{"POST", "PATCH"} {
|
||||||
|
t.Run(method, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(method, "/api/config", strings.NewReader("log:\n level: info\n"))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
configHandler(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusForbidden, w.Code)
|
||||||
|
require.Contains(t, w.Body.String(), "read-only")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,11 +20,15 @@ func LoadConfig(v any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var configMu sync.Mutex
|
var configMu sync.Mutex
|
||||||
|
var ConfigReadOnly bool
|
||||||
|
|
||||||
func PatchConfig(path []string, value any) error {
|
func PatchConfig(path []string, value any) error {
|
||||||
if ConfigPath == "" {
|
if ConfigPath == "" {
|
||||||
return errors.New("config file disabled")
|
return errors.New("config file disabled")
|
||||||
}
|
}
|
||||||
|
if ConfigReadOnly {
|
||||||
|
return errors.New("config is read-only")
|
||||||
|
}
|
||||||
|
|
||||||
configMu.Lock()
|
configMu.Lock()
|
||||||
defer configMu.Unlock()
|
defer configMu.Unlock()
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPatchConfigReadOnly(t *testing.T) {
|
||||||
|
prevPath := ConfigPath
|
||||||
|
prevReadOnly := ConfigReadOnly
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ConfigPath = prevPath
|
||||||
|
ConfigReadOnly = prevReadOnly
|
||||||
|
})
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(""), 0644))
|
||||||
|
|
||||||
|
ConfigPath = path
|
||||||
|
ConfigReadOnly = true
|
||||||
|
|
||||||
|
err := PatchConfig([]string{"streams", "cam"}, "rtsp://example.com")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.EqualError(t, err, "config is read-only")
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AlexxIT/go2rtc/internal/api"
|
||||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,6 +13,10 @@ func apiFFmpeg(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if api.IsReadOnly() {
|
||||||
|
api.ReadOnlyError(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
dst := query.Get("dst")
|
dst := query.Get("dst")
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package ffmpeg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AlexxIT/go2rtc/internal/api"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApiFFmpegReadOnly(t *testing.T) {
|
||||||
|
prevReadOnly := api.ReadOnly
|
||||||
|
t.Cleanup(func() {
|
||||||
|
api.ReadOnly = prevReadOnly
|
||||||
|
})
|
||||||
|
|
||||||
|
api.ReadOnly = true
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/ffmpeg?dst=cam&text=hello", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
apiFFmpeg(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusForbidden, w.Code)
|
||||||
|
}
|
||||||
@@ -43,6 +43,10 @@ func apiDiscovery(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func apiHomekit(w http.ResponseWriter, r *http.Request) {
|
func apiHomekit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if api.IsReadOnly() && r.Method != "GET" {
|
||||||
|
api.ReadOnlyError(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package homekit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AlexxIT/go2rtc/internal/api"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApiHomekitReadOnly(t *testing.T) {
|
||||||
|
prevReadOnly := api.ReadOnly
|
||||||
|
t.Cleanup(func() {
|
||||||
|
api.ReadOnly = prevReadOnly
|
||||||
|
})
|
||||||
|
|
||||||
|
api.ReadOnly = true
|
||||||
|
|
||||||
|
t.Run("POST blocked", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("POST", "/api/homekit", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
apiHomekit(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusForbidden, w.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET allowed", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/api/homekit", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
apiHomekit(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -111,6 +111,14 @@ func handleTCP(rawURL string) (core.Producer, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func apiStream(w http.ResponseWriter, r *http.Request) {
|
func apiStream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if api.IsReadOnly() {
|
||||||
|
switch r.Method {
|
||||||
|
case "PUT", "PATCH", "POST", "DELETE":
|
||||||
|
api.ReadOnlyError(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dst := r.URL.Query().Get("dst")
|
dst := r.URL.Query().Get("dst")
|
||||||
stream := streams.Get(dst)
|
stream := streams.Get(dst)
|
||||||
if stream == nil {
|
if stream == nil {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdhttp "net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AlexxIT/go2rtc/internal/api"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApiStreamReadOnly(t *testing.T) {
|
||||||
|
prevReadOnly := api.ReadOnly
|
||||||
|
t.Cleanup(func() {
|
||||||
|
api.ReadOnly = prevReadOnly
|
||||||
|
})
|
||||||
|
|
||||||
|
api.ReadOnly = true
|
||||||
|
|
||||||
|
for _, method := range []string{"PUT", "PATCH", "POST", "DELETE"} {
|
||||||
|
t.Run(method, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(method, "/api/stream?dst=test", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
apiStream(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, stdhttp.StatusForbidden, w.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,10 @@ var Auth struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func apiHandle(w http.ResponseWriter, r *http.Request) {
|
func apiHandle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if api.IsReadOnly() && r.Method != "GET" {
|
||||||
|
api.ReadOnlyError(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case "GET":
|
case "GET":
|
||||||
if Auth.UserData == nil {
|
if Auth.UserData == nil {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package roborock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AlexxIT/go2rtc/internal/api"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApiHandleReadOnly(t *testing.T) {
|
||||||
|
prevReadOnly := api.ReadOnly
|
||||||
|
t.Cleanup(func() {
|
||||||
|
api.ReadOnly = prevReadOnly
|
||||||
|
})
|
||||||
|
|
||||||
|
api.ReadOnly = true
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/roborock", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
apiHandle(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusForbidden, w.Code)
|
||||||
|
}
|
||||||
@@ -12,6 +12,13 @@ import (
|
|||||||
|
|
||||||
func apiStreams(w http.ResponseWriter, r *http.Request) {
|
func apiStreams(w http.ResponseWriter, r *http.Request) {
|
||||||
w = creds.SecretResponse(w)
|
w = creds.SecretResponse(w)
|
||||||
|
if api.IsReadOnly() {
|
||||||
|
switch r.Method {
|
||||||
|
case "PUT", "PATCH", "POST", "DELETE":
|
||||||
|
api.ReadOnlyError(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
src := query.Get("src")
|
src := query.Get("src")
|
||||||
@@ -130,6 +137,13 @@ func apiStreamsDOT(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func apiPreload(w http.ResponseWriter, r *http.Request) {
|
func apiPreload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if api.IsReadOnly() {
|
||||||
|
switch r.Method {
|
||||||
|
case "PUT", "DELETE":
|
||||||
|
api.ReadOnlyError(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
// GET - return all preloads
|
// GET - return all preloads
|
||||||
if r.Method == "GET" {
|
if r.Method == "GET" {
|
||||||
api.ResponseJSON(w, GetPreloads())
|
api.ResponseJSON(w, GetPreloads())
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package streams
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AlexxIT/go2rtc/internal/api"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApiStreamsReadOnly(t *testing.T) {
|
||||||
|
prevReadOnly := api.ReadOnly
|
||||||
|
t.Cleanup(func() {
|
||||||
|
api.ReadOnly = prevReadOnly
|
||||||
|
})
|
||||||
|
|
||||||
|
api.ReadOnly = true
|
||||||
|
|
||||||
|
for _, method := range []string{"PUT", "PATCH", "POST", "DELETE"} {
|
||||||
|
t.Run(method, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(method, "/api/streams?src=test", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
apiStreams(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusForbidden, w.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("GET allowed", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/api/streams", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
apiStreams(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApiPreloadReadOnly(t *testing.T) {
|
||||||
|
prevReadOnly := api.ReadOnly
|
||||||
|
t.Cleanup(func() {
|
||||||
|
api.ReadOnly = prevReadOnly
|
||||||
|
})
|
||||||
|
|
||||||
|
api.ReadOnly = true
|
||||||
|
|
||||||
|
for _, method := range []string{"PUT", "DELETE"} {
|
||||||
|
t.Run(method, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(method, "/api/preload?src=test", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
apiPreload(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusForbidden, w.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("GET allowed", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/api/preload", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
apiPreload(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -21,6 +21,14 @@ const MimeSDP = "application/sdp"
|
|||||||
var sessions = map[string]*webrtc.Conn{}
|
var sessions = map[string]*webrtc.Conn{}
|
||||||
|
|
||||||
func syncHandler(w http.ResponseWriter, r *http.Request) {
|
func syncHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if api.IsReadOnly() {
|
||||||
|
switch r.Method {
|
||||||
|
case "POST", "PATCH", "DELETE":
|
||||||
|
api.ReadOnlyError(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case "POST":
|
case "POST":
|
||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AlexxIT/go2rtc/internal/api"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSyncHandlerReadOnly(t *testing.T) {
|
||||||
|
prevReadOnly := api.ReadOnly
|
||||||
|
t.Cleanup(func() {
|
||||||
|
api.ReadOnly = prevReadOnly
|
||||||
|
})
|
||||||
|
|
||||||
|
api.ReadOnly = true
|
||||||
|
|
||||||
|
for _, method := range []string{"POST", "PATCH", "DELETE"} {
|
||||||
|
t.Run(method, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(method, "/api/webrtc?dst=test", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
syncHandler(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusForbidden, w.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,10 @@ func getCloud(email string) (*wyze.Cloud, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func apiWyze(w http.ResponseWriter, r *http.Request) {
|
func apiWyze(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if api.IsReadOnly() && r.Method != "GET" {
|
||||||
|
api.ReadOnlyError(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case "GET":
|
case "GET":
|
||||||
apiDeviceList(w, r)
|
apiDeviceList(w, r)
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package wyze
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AlexxIT/go2rtc/internal/api"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApiWyzeReadOnly(t *testing.T) {
|
||||||
|
prevReadOnly := api.ReadOnly
|
||||||
|
t.Cleanup(func() {
|
||||||
|
api.ReadOnly = prevReadOnly
|
||||||
|
})
|
||||||
|
|
||||||
|
api.ReadOnly = true
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/wyze", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
apiWyze(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusForbidden, w.Code)
|
||||||
|
}
|
||||||
@@ -219,6 +219,10 @@ func wakeUpCamera(url *url.URL) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func apiXiaomi(w http.ResponseWriter, r *http.Request) {
|
func apiXiaomi(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if api.IsReadOnly() && r.Method != "GET" {
|
||||||
|
api.ReadOnlyError(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case "GET":
|
case "GET":
|
||||||
apiDeviceList(w, r)
|
apiDeviceList(w, r)
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package xiaomi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AlexxIT/go2rtc/internal/api"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApiXiaomiReadOnly(t *testing.T) {
|
||||||
|
prevReadOnly := api.ReadOnly
|
||||||
|
t.Cleanup(func() {
|
||||||
|
api.ReadOnly = prevReadOnly
|
||||||
|
})
|
||||||
|
|
||||||
|
api.ReadOnly = true
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/xiaomi", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
apiXiaomi(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusForbidden, w.Code)
|
||||||
|
}
|
||||||
@@ -52,8 +52,24 @@
|
|||||||
drawTable(table, await r.json());
|
drawTable(table, await r.json());
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if (!window.go2rtcReady) return;
|
||||||
|
window.go2rtcReady.then(data => {
|
||||||
|
if (!data || !data.read_only) return;
|
||||||
|
const banner = document.getElementById('readonly-banner');
|
||||||
|
if (banner) banner.style.display = 'block';
|
||||||
|
document.querySelectorAll('main input, main select, main button, main textarea').forEach(el => {
|
||||||
|
el.disabled = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
<div id="readonly-banner" style="display: none; padding: 10px; background: #ffe9e9; border: 1px solid #f0b4b4;">
|
||||||
|
Read-only mode: add actions are disabled.
|
||||||
|
</div>
|
||||||
<button id="stream">Temporary stream</button>
|
<button id="stream">Temporary stream</button>
|
||||||
<div>
|
<div>
|
||||||
<form id="stream-form">
|
<form id="stream-form">
|
||||||
|
|||||||
+46
-3
@@ -1182,18 +1182,61 @@
|
|||||||
|
|
||||||
let dump;
|
let dump;
|
||||||
|
|
||||||
document.getElementById('save').addEventListener('click', async () => {
|
const saveButton = document.getElementById('save');
|
||||||
|
const readOnlyWarning = 'Enabling read_only: true cannot be reverted remotely. To disable it you must edit the config file on the server manually. Continue?';
|
||||||
|
const hasReadOnlyTrue = (text) => {
|
||||||
|
if (!text) return false;
|
||||||
|
return /(^|\n)\s*read_only\s*:\s*true\s*(#.*)?$/m.test(text);
|
||||||
|
};
|
||||||
|
const applyReadOnly = () => {
|
||||||
|
saveButton.disabled = true;
|
||||||
|
saveButton.title = 'Read-only mode';
|
||||||
|
editor.updateOptions({readOnly: true});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window.go2rtcReady) {
|
||||||
|
window.go2rtcReady.then(data => {
|
||||||
|
if (data && data.read_only) applyReadOnly();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveButton.addEventListener('click', async () => {
|
||||||
let r = await fetch('api/config', {cache: 'no-cache'});
|
let r = await fetch('api/config', {cache: 'no-cache'});
|
||||||
if (r.ok && dump !== await r.text()) {
|
if (r.ok && dump !== await r.text()) {
|
||||||
alert('Config was changed from another place. Refresh the page and make changes again');
|
alert('Config was changed from another place. Refresh the page and make changes again');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
r = await fetch('api/config', {method: 'POST', body: editor.getValue()});
|
const nextValue = editor.getValue();
|
||||||
|
if (hasReadOnlyTrue(nextValue) && !hasReadOnlyTrue(dump)) {
|
||||||
|
if (!confirm(readOnlyWarning)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
r = await fetch('api/config', {method: 'POST', body: nextValue});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
alert('OK');
|
alert('OK');
|
||||||
dump = editor.getValue();
|
dump = nextValue;
|
||||||
await fetch('api/restart', {method: 'POST'});
|
await fetch('api/restart', {method: 'POST'});
|
||||||
|
|
||||||
|
// Poll server until it's back online before reloading
|
||||||
|
const waitForServer = async () => {
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
try {
|
||||||
|
const response = await fetch('api/config', {cache: 'no-cache'});
|
||||||
|
if (response.ok || response.status === 410) {
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Server still down, continue polling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: reload after 10 seconds even if server check fails
|
||||||
|
location.reload();
|
||||||
|
};
|
||||||
|
waitForServer();
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
alert(await r.text());
|
alert(await r.text());
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-2
@@ -47,11 +47,18 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const templates = [
|
let templates = [
|
||||||
'<a href="stream.html?src={name}">stream</a>',
|
'<a href="stream.html?src={name}">stream</a>',
|
||||||
'<a href="links.html?src={name}">links</a>',
|
'<a href="links.html?src={name}">links</a>',
|
||||||
'<a href="#" data-name="{name}">delete</a>',
|
'<a href="#" data-name="{name}">delete</a>',
|
||||||
];
|
];
|
||||||
|
let readOnly = false;
|
||||||
|
|
||||||
|
const applyReadOnly = (flag) => {
|
||||||
|
if (!flag || readOnly) return;
|
||||||
|
readOnly = true;
|
||||||
|
templates = templates.filter(link => link.indexOf('delete') < 0);
|
||||||
|
};
|
||||||
|
|
||||||
document.querySelector('.controls > button')
|
document.querySelector('.controls > button')
|
||||||
.addEventListener('click', () => {
|
.addEventListener('click', () => {
|
||||||
@@ -263,6 +270,8 @@
|
|||||||
|
|
||||||
function updateInfo() {
|
function updateInfo() {
|
||||||
return fetch(infoURL, {cache: 'no-cache'}).then(r => r.json()).then(data => {
|
return fetch(infoURL, {cache: 'no-cache'}).then(r => r.json()).then(data => {
|
||||||
|
applyReadOnly(Boolean(data.read_only));
|
||||||
|
|
||||||
const cpuUsage = clampPercent(data.system?.cpu_usage);
|
const cpuUsage = clampPercent(data.system?.cpu_usage);
|
||||||
const memUsed = toNumber(data.system?.mem_used);
|
const memUsed = toNumber(data.system?.mem_used);
|
||||||
const memTotal = toNumber(data.system?.mem_total);
|
const memTotal = toNumber(data.system?.mem_total);
|
||||||
@@ -287,11 +296,21 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const applyInfo = (data) => {
|
||||||
|
applyReadOnly(Boolean(data.read_only));
|
||||||
updateInfo().then(isSupported => {
|
updateInfo().then(isSupported => {
|
||||||
if (isSupported) startInfoUpdates();
|
if (isSupported) startInfoUpdates();
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
reload();
|
if (window.go2rtcReady) {
|
||||||
|
window.go2rtcReady.then(data => {
|
||||||
|
if (data) applyInfo(data);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const url = new URL('api', location.href);
|
||||||
|
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => applyInfo(data));
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -154,6 +154,21 @@ Telegram: rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx</pre>
|
|||||||
fetch(url, {method: 'POST'});
|
fetch(url, {method: 'POST'});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
const applyReadOnly = () => {
|
||||||
|
const ids = ['play-send', 'play-url', 'pub-send', 'pub-url'];
|
||||||
|
ids.forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.disabled = true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window.go2rtcReady) {
|
||||||
|
window.go2rtcReady.then(data => {
|
||||||
|
if (data && data.read_only) applyReadOnly();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<div id="webrtc">
|
<div id="webrtc">
|
||||||
<h2>WebRTC Magic</h2>
|
<h2>WebRTC Magic</h2>
|
||||||
|
|||||||
+21
@@ -136,3 +136,24 @@ document.body.innerHTML = `
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
` + document.body.innerHTML;
|
` + document.body.innerHTML;
|
||||||
|
|
||||||
|
window.go2rtcReady = (async () => {
|
||||||
|
try {
|
||||||
|
const url = new URL('api', location.href);
|
||||||
|
const r = await fetch(url, {cache: 'no-cache'});
|
||||||
|
if (!r.ok) return null;
|
||||||
|
const data = await r.json();
|
||||||
|
window.go2rtcInfo = data;
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.go2rtcReady.then(data => {
|
||||||
|
if (!data || !data.read_only) return;
|
||||||
|
const links = document.querySelectorAll('nav a[href="add.html"], nav a[href="config.html"]');
|
||||||
|
links.forEach(link => {
|
||||||
|
link.style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -68,6 +68,11 @@
|
|||||||
"password": {
|
"password": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"read_only": {
|
||||||
|
"description": "Disable write actions in WebUI/API",
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
"local_auth": {
|
"local_auth": {
|
||||||
"description": "Enable auth check for localhost requests",
|
"description": "Enable auth check for localhost requests",
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
|
|||||||
Reference in New Issue
Block a user