mirror of
https://github.com/tronbyt/server.git
synced 2026-08-31 06:57:10 +02:00
feat: expose app config and render fields via the installations API (#886)
Build and test / Lint & Quality Checks (push) Has been cancelled
Build and test / build-and-test (tronbyt-server-darwin-arm64, arm64, darwin, macos-26) (push) Has been cancelled
Build and test / build-and-test (tronbyt-server-linux-amd64, amd64, linux, ubuntu-24.04) (push) Has been cancelled
Build and test / build-and-test (tronbyt-server-linux-arm64, arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
Build and test / build-and-test (tronbyt-server-windows-amd64.exe, amd64, windows, windows-2025) (push) Has been cancelled
Create and publish a container image / build-and-push-image (push) Has been cancelled
Build and test / Create Release (push) Has been cancelled
Build and test / Lint & Quality Checks (push) Has been cancelled
Build and test / build-and-test (tronbyt-server-darwin-arm64, arm64, darwin, macos-26) (push) Has been cancelled
Build and test / build-and-test (tronbyt-server-linux-amd64, amd64, linux, ubuntu-24.04) (push) Has been cancelled
Build and test / build-and-test (tronbyt-server-linux-arm64, arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
Build and test / build-and-test (tronbyt-server-windows-amd64.exe, amd64, windows, windows-2025) (push) Has been cancelled
Create and publish a container image / build-and-push-image (push) Has been cancelled
Build and test / Create Release (push) Has been cancelled
* feat: expose app config and render fields via the installations API Follows #783, which brought the schedule fields across from the web UI. The same gap remained for everything else the app config page writes: config itself, autoPin, colorFilter and showFullAnimation were reachable only through a session, so an API-driven setup could install and schedule an app but never configure it. PATCH /v0/devices/{id}/installations/{iname} gains all four. autoPin, colorFilter and showFullAnimation are added to the GET payload too, so a client can diff current state before writing. config is write-only. It holds whatever the app's schema defines, which for many apps means API keys and OAuth tokens, and a device API key is a lower bar than a logged-in session — so it can be set but is never read back. It replaces the whole map, matching handleConfigAppPost; there is no per-key merge. Two behavior changes worth calling out: - PATCH previously encoded data.App directly, which carried the app's entire config — tokens included — into the response body, while GET deliberately omits it. It now answers with the same AppPayload shape GET uses. This changes the response from snake_case to camelCase for anyone reading it; TestHandlePatchInstallationSchedule was asserting on the old shape and is updated. - An unknown colorFilter is now a 400 rather than being stored. The validity list is the one the config page already offers. API.md: document the new fields, and correct the installations example, which still showed iname/display_time/u_interval/last_render and a config key that AppPayload has never returned. * fix: validate the whole installation update before applying any of it handlePatchInstallation applied fields as it walked them, so a request carrying one good field and one bad one left the good half in place: a `{"pinned": true, "colorFilter": "chartreuse"}` PATCH saved the device pin and then answered 400, and disabling an app deleted its rendered webp files before a later field could reject the request. Move the two blocks with effects outside the in-memory app -- the enabled block's file deletion and the pinned block's device save -- to the end, after every validating field. The app itself is loaded per request, so the staged in-memory changes are discarded when a validation returns early and only the final Save persists anything. Also address review feedback on the new test: use testify per AGENTS.md, and rename the `clear` local, which the predeclared linter rejects. * fix: commit the installation update before deleting its renders Disabling an app deleted its webp files and then saved the row, so a failed save left the app enabled in the database with its renders gone. The pin was a separate write for the same reason: it landed even when the app save that followed it failed. Write the app row and the device's pinned_app in one transaction, using the same column-scoped update handleDeleteApp already uses, and move the render cleanup after the commit. Cleanup is now post-commit reconciliation: it can only leave stale files behind, so it logs instead of failing a request whose state change already succeeded. A pin write that fails now reports "Failed to update app" rather than "Failed to update device pin status" -- there is one operation to fail. * fix: treat an installation name as untrusted when cleaning up renders Every iname the UI and the API create is a server-generated number, but handleImportDeviceConfig creates apps straight from an uploaded config and stores whatever iname it carries. That value then reaches the filesystem twice during disable cleanup: - filepath.Glob(webpDir + "*-" + iname + ".webp"): an iname of "*" matches, and deletes, every render on the device. - filepath.Join(webpDir, "pushed", iname + ".webp"): an iname of "../../victim" resolves out of the device directory entirely, into the sibling directory holding another device's renders. Reject an iname that is not a plain path component, and match renders by name rather than by glob, so metacharacters cannot widen the pattern. TestRemoveAppRendersRejectsUnsafeIname covers both; each half fails against the previous implementation. This only hardens the disable path. The root cause is that the import handler stores an unvalidated iname, and the other code that builds paths from one is untouched here.
This commit is contained in:
@@ -153,23 +153,40 @@ Returns all app installations on a device.
|
||||
{
|
||||
"installations": [
|
||||
{
|
||||
"id": 1,
|
||||
"iname": "my-clock",
|
||||
"name": "Clock",
|
||||
"app_id": "clock",
|
||||
"id": "my-clock",
|
||||
"appID": "clock",
|
||||
"enabled": true,
|
||||
"display_time": 30,
|
||||
"u_interval": 0,
|
||||
"last_render": "2024-01-01T00:00:00Z",
|
||||
"config": {
|
||||
"timezone": "America/New_York"
|
||||
},
|
||||
"pinned": false
|
||||
"pinned": false,
|
||||
"pushed": false,
|
||||
"renderIntervalMin": 0,
|
||||
"displayTimeSec": 30,
|
||||
"lastRenderAt": 1704067200,
|
||||
"isInactive": false,
|
||||
|
||||
"startTime": "09:00",
|
||||
"endTime": "17:00",
|
||||
"days": ["monday", "wednesday", "friday"],
|
||||
|
||||
"useCustomRecurrence": false,
|
||||
"recurrenceType": "",
|
||||
"recurrenceInterval": 0,
|
||||
"recurrencePattern": null,
|
||||
"recurrenceStartDate": null,
|
||||
"recurrenceEndDate": null,
|
||||
|
||||
"autoPin": false,
|
||||
"colorFilter": null,
|
||||
"showFullAnimation": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
App `config` is **not** returned. It holds whatever the app's schema defines,
|
||||
which for many apps includes API keys and OAuth tokens, and a device API key is
|
||||
a lower bar than a logged-in session. Config can be written (see below) but
|
||||
never read back.
|
||||
|
||||
### Get Installation
|
||||
|
||||
```
|
||||
@@ -185,7 +202,8 @@ PATCH /v0/devices/{id}/installations/{iname}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Update installation settings. All fields are optional.
|
||||
Update installation settings. All fields are optional; omitting one leaves it
|
||||
alone.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
@@ -193,11 +211,29 @@ Update installation settings. All fields are optional.
|
||||
"enabled": true,
|
||||
"pinned": false,
|
||||
"renderIntervalMin": 5,
|
||||
"displayTimeSec": 30
|
||||
"displayTimeSec": 30,
|
||||
|
||||
"startTime": "09:00",
|
||||
"endTime": "17:00",
|
||||
"days": ["monday", "wednesday", "friday"],
|
||||
|
||||
"autoPin": false,
|
||||
"colorFilter": "dimmed",
|
||||
"showFullAnimation": "true",
|
||||
|
||||
"config": { "timezone": "America/New_York" }
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** Updated installation object.
|
||||
| Field | Notes |
|
||||
|-------|-------|
|
||||
| `startTime` / `endTime` | `"HH:MM"`. `""` clears. A start later than the end wraps overnight. |
|
||||
| `days` | Lowercase day names. `[]` means every day. |
|
||||
| `colorFilter` | One of the filters the app config page offers. `""` or `"inherit"` falls back to the device setting. |
|
||||
| `showFullAnimation` | `"true"` / `"false"`, or `"auto"` to inherit. Lets an animation run past the app's display time. |
|
||||
| `config` | Replaces the app's whole config map — there is no per-key merge, so send the full object. **Write-only:** it is never returned by GET or in this response. |
|
||||
|
||||
**Response:** Updated installation object, in the same shape `GET` returns.
|
||||
|
||||
### Delete Installation
|
||||
|
||||
|
||||
+158
-42
@@ -205,6 +205,14 @@ type AppPayload struct {
|
||||
RecurrencePattern map[string]any `json:"recurrencePattern"`
|
||||
RecurrenceStartDate *string `json:"recurrenceStartDate"`
|
||||
RecurrenceEndDate *string `json:"recurrenceEndDate"`
|
||||
|
||||
// Render behavior. Config is deliberately absent: it holds whatever the
|
||||
// app's schema defines, which for many apps includes API keys and OAuth
|
||||
// tokens, and a device API key is a lower bar than a session. It can be
|
||||
// written via PATCH but is never read back.
|
||||
AutoPin bool `json:"autoPin"`
|
||||
ColorFilter *data.ColorFilter `json:"colorFilter"`
|
||||
ShowFullAnimation *bool `json:"showFullAnimation"`
|
||||
}
|
||||
|
||||
func (s *Server) toAppPayload(device *data.Device, app *data.App) AppPayload {
|
||||
@@ -230,6 +238,10 @@ func (s *Server) toAppPayload(device *data.Device, app *data.App) AppPayload {
|
||||
RecurrencePattern: app.RecurrencePattern,
|
||||
RecurrenceStartDate: app.RecurrenceStartDate,
|
||||
RecurrenceEndDate: app.RecurrenceEndDate,
|
||||
|
||||
AutoPin: app.AutoPin,
|
||||
ColorFilter: app.ColorFilter,
|
||||
ShowFullAnimation: app.ShowFullAnimation,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -765,6 +777,17 @@ type InstallationUpdate struct {
|
||||
RecurrencePattern *map[string]any `json:"recurrencePattern"`
|
||||
RecurrenceStartDate *string `json:"recurrenceStartDate"`
|
||||
RecurrenceEndDate *string `json:"recurrenceEndDate"`
|
||||
|
||||
// Render behavior
|
||||
AutoPin *bool `json:"autoPin"`
|
||||
ColorFilter *string `json:"colorFilter"` // "" or "inherit" clears
|
||||
ShowFullAnimation *string `json:"showFullAnimation"` // "auto" clears; else a bool
|
||||
|
||||
// App config, matching what the config page already stores. Write-only:
|
||||
// GET never returns it, because it commonly holds API keys and OAuth
|
||||
// tokens. Replaces the whole map, as the config page does -- there is no
|
||||
// per-key merge.
|
||||
Config *map[string]any `json:"config"`
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchInstallation(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -784,52 +807,12 @@ func (s *Server) handlePatchInstallation(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if update.Enabled != nil {
|
||||
app.Enabled = *update.Enabled
|
||||
if !app.Enabled {
|
||||
// Delete associated webp files when app is disabled
|
||||
webpDir, err := s.ensureDeviceImageDir(device.ID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get device webp directory for app disable cleanup", "device_id", device.ID, "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
matches, _ := filepath.Glob(filepath.Join(webpDir, fmt.Sprintf("*-%s.webp", app.Iname)))
|
||||
for _, match := range matches {
|
||||
if err := os.Remove(match); err != nil {
|
||||
slog.Error("Failed to remove webp file on app disable", "path", match, "error", err)
|
||||
}
|
||||
}
|
||||
// Also check for pushed webp files
|
||||
pushedWebpPath := filepath.Join(webpDir, "pushed", fmt.Sprintf("%s.webp", app.Iname))
|
||||
if _, err := os.Stat(pushedWebpPath); err == nil {
|
||||
if err := os.Remove(pushedWebpPath); err != nil {
|
||||
slog.Error("Failed to remove pushed webp file on app disable", "path", pushedWebpPath, "error", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Reset LastRender when app is enabled
|
||||
app.LastRender = time.Time{}
|
||||
}
|
||||
}
|
||||
if update.RenderIntervalMin != nil {
|
||||
app.UInterval = *update.RenderIntervalMin
|
||||
}
|
||||
if update.DisplayTimeSec != nil {
|
||||
app.DisplayTime = *update.DisplayTimeSec
|
||||
}
|
||||
if update.Pinned != nil {
|
||||
if *update.Pinned {
|
||||
device.PinnedApp = &app.Iname
|
||||
} else if device.PinnedApp != nil && *device.PinnedApp == app.Iname {
|
||||
device.PinnedApp = nil
|
||||
}
|
||||
// Save device for pinned change
|
||||
if err := s.DB.Omit("Apps").Save(device).Error; err != nil {
|
||||
http.Error(w, "Failed to update device pin status", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule fields
|
||||
if update.StartTime != nil {
|
||||
@@ -911,21 +894,154 @@ func (s *Server) handlePatchInstallation(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.DB.Save(app).Error; err != nil {
|
||||
// Render behavior
|
||||
if update.AutoPin != nil {
|
||||
app.AutoPin = *update.AutoPin
|
||||
}
|
||||
if update.ColorFilter != nil {
|
||||
switch *update.ColorFilter {
|
||||
case "", string(data.ColorFilterInherit):
|
||||
app.ColorFilter = nil
|
||||
default:
|
||||
if !s.isValidColorFilter(*update.ColorFilter) {
|
||||
http.Error(w, "Invalid colorFilter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
val := data.ColorFilter(*update.ColorFilter)
|
||||
app.ColorFilter = &val
|
||||
}
|
||||
}
|
||||
if update.ShowFullAnimation != nil {
|
||||
switch *update.ShowFullAnimation {
|
||||
case "", "auto":
|
||||
app.ShowFullAnimation = nil
|
||||
default:
|
||||
val, err := strconv.ParseBool(*update.ShowFullAnimation)
|
||||
if err != nil {
|
||||
http.Error(w, `Invalid showFullAnimation: want "auto", "true" or "false"`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
app.ShowFullAnimation = &val
|
||||
}
|
||||
}
|
||||
if update.Config != nil {
|
||||
app.Config = *update.Config
|
||||
}
|
||||
|
||||
// Side effects last. Everything above either validates or stages an
|
||||
// in-memory change, so a request carrying one good field and one bad one
|
||||
// returns 400 without having deleted a render or repinned the device.
|
||||
if update.Enabled != nil {
|
||||
app.Enabled = *update.Enabled
|
||||
if app.Enabled {
|
||||
// Reset LastRender when app is enabled
|
||||
app.LastRender = time.Time{}
|
||||
}
|
||||
}
|
||||
if update.Pinned != nil {
|
||||
if *update.Pinned {
|
||||
device.PinnedApp = &app.Iname
|
||||
} else if device.PinnedApp != nil && *device.PinnedApp == app.Iname {
|
||||
device.PinnedApp = nil
|
||||
}
|
||||
}
|
||||
|
||||
// The pin lives on the device row and everything else on the app row, so
|
||||
// write both in one transaction rather than letting a failed app save
|
||||
// leave the pin moved.
|
||||
if err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if update.Pinned != nil {
|
||||
var pinned any
|
||||
if device.PinnedApp != nil {
|
||||
pinned = *device.PinnedApp
|
||||
}
|
||||
if _, err := gorm.G[data.Device](tx).Where("id = ?", device.ID).Update(r.Context(), "pinned_app", pinned); err != nil {
|
||||
return fmt.Errorf("update device pin status: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Save(app).Error
|
||||
}); err != nil {
|
||||
slog.Error("Failed to update installation", "device_id", device.ID, "iname", app.Iname, "error", err)
|
||||
http.Error(w, "Failed to update app", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Render cleanup is post-commit reconciliation: deleting an app's webp
|
||||
// files cannot be rolled back, so it waits until the disable is durable.
|
||||
// A failure here leaves stale files, not a wrong app state, so it logs
|
||||
// rather than failing a request that already succeeded.
|
||||
if update.Enabled != nil && !app.Enabled {
|
||||
s.removeAppRenders(device.ID, app.Iname)
|
||||
}
|
||||
|
||||
// Notify Dashboard
|
||||
user := GetUser(r)
|
||||
s.notifyDashboard(user.Username, WSEvent{Type: "apps_changed", DeviceID: device.ID})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(app); err != nil {
|
||||
// Respond with the same shape GET returns. This used to encode data.App
|
||||
// directly, which carried the app's whole config -- API keys and OAuth
|
||||
// tokens included -- back out over the wire, contradicting the omission
|
||||
// of config from the GET payload.
|
||||
if err := json.NewEncoder(w).Encode(s.toAppPayload(device, app)); err != nil {
|
||||
slog.Error("Failed to encode app", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// removeAppRenders deletes the rendered webp files belonging to an
|
||||
// installation, both the timestamped renders and any pushed image.
|
||||
func (s *Server) removeAppRenders(deviceID, iname string) {
|
||||
// iname becomes a path component here. Every app the UI and the API
|
||||
// create gets a server-generated numeric iname, but handleImportDeviceConfig
|
||||
// stores whatever an uploaded config carries, so treat it as untrusted:
|
||||
// only a plain filename component is safe to build a path from.
|
||||
if !isSafePathComponent(iname) {
|
||||
slog.Error("Refusing render cleanup for unsafe installation name", "device_id", deviceID, "iname", iname)
|
||||
return
|
||||
}
|
||||
|
||||
webpDir, err := s.ensureDeviceImageDir(deviceID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get device webp directory for app disable cleanup", "device_id", deviceID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Match on the name rather than globbing: glob metacharacters in an
|
||||
// imported iname would otherwise widen the pattern past this app's files.
|
||||
entries, err := os.ReadDir(webpDir)
|
||||
if err != nil {
|
||||
slog.Error("Failed to read device webp directory for app disable cleanup", "device_id", deviceID, "error", err)
|
||||
return
|
||||
}
|
||||
suffix := fmt.Sprintf("-%s.webp", iname)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), suffix) {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(webpDir, entry.Name())
|
||||
if err := os.Remove(path); err != nil {
|
||||
slog.Error("Failed to remove webp file on app disable", "path", path, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for pushed webp files
|
||||
pushedWebpPath := filepath.Join(webpDir, "pushed", fmt.Sprintf("%s.webp", iname))
|
||||
if _, err := os.Stat(pushedWebpPath); err == nil {
|
||||
if err := os.Remove(pushedWebpPath); err != nil {
|
||||
slog.Error("Failed to remove pushed webp file on app disable", "path", pushedWebpPath, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isSafePathComponent reports whether name can be joined into a path as a
|
||||
// single element without escaping the directory it is joined to.
|
||||
func isSafePathComponent(name string) bool {
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return false
|
||||
}
|
||||
return filepath.Base(name) == name
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteInstallationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
installID := filepath.Base(r.PathValue("iname"))
|
||||
|
||||
|
||||
@@ -933,8 +933,10 @@ func TestHandlePatchInstallationSchedule(t *testing.T) {
|
||||
t.Errorf("Expected recurrenceEndDate '2026-12-31', got %v", updated.RecurrenceEndDate)
|
||||
}
|
||||
|
||||
// Verify the response JSON also contains the schedule fields
|
||||
var respApp data.App
|
||||
// Verify the response JSON also contains the schedule fields. PATCH now
|
||||
// answers with the same AppPayload shape GET uses, rather than a raw
|
||||
// data.App.
|
||||
var respApp AppPayload
|
||||
if err := json.NewDecoder(rr.Body).Decode(&respApp); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
@@ -1449,3 +1451,171 @@ func isAnonymousEphemeral(name string) bool {
|
||||
inner := name[2 : len(name)-5] // strip "__" and ".webp"
|
||||
return !strings.Contains(inner, "_")
|
||||
}
|
||||
|
||||
func TestHandlePatchInstallationConfigAndRenderFields(t *testing.T) {
|
||||
s := newTestServerAPI(t)
|
||||
apiKey := "test_api_key"
|
||||
deviceID := "testdevice"
|
||||
installID := "configapp"
|
||||
|
||||
app := data.App{
|
||||
DeviceID: deviceID,
|
||||
Iname: installID,
|
||||
Name: "Config App",
|
||||
UInterval: 10,
|
||||
DisplayTime: 10,
|
||||
Enabled: true,
|
||||
Order: 0,
|
||||
}
|
||||
require.NoError(t, gorm.G[data.App](s.DB).Create(context.Background(), &app), "Failed to create dummy app")
|
||||
|
||||
autoPin := true
|
||||
colorFilter := "dimmed"
|
||||
showFullAnimation := "true"
|
||||
cfg := map[string]any{"stop_id": "place-north", "api_key": "secret-token"}
|
||||
|
||||
update := InstallationUpdate{
|
||||
AutoPin: &autoPin,
|
||||
ColorFilter: &colorFilter,
|
||||
ShowFullAnimation: &showFullAnimation,
|
||||
Config: &cfg,
|
||||
}
|
||||
body, err := json.Marshal(update)
|
||||
require.NoError(t, err)
|
||||
req := newAPIRequest("PATCH", fmt.Sprintf("/v0/devices/%s/installations/%s", deviceID, installID), apiKey, body)
|
||||
rr := httptest.NewRecorder()
|
||||
s.ServeHTTP(rr, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rr.Code, "PATCH body: %s", rr.Body.String())
|
||||
|
||||
updated, err := gorm.G[data.App](s.DB).Where("iname = ?", installID).First(context.Background())
|
||||
require.NoError(t, err, "Failed to fetch updated app state")
|
||||
assert.True(t, updated.AutoPin, "Expected autoPin true")
|
||||
if assert.NotNil(t, updated.ColorFilter, "Expected colorFilter dimmed") {
|
||||
assert.Equal(t, data.ColorFilterDimmed, *updated.ColorFilter)
|
||||
}
|
||||
if assert.NotNil(t, updated.ShowFullAnimation, "Expected showFullAnimation true") {
|
||||
assert.True(t, *updated.ShowFullAnimation)
|
||||
}
|
||||
assert.Equal(t, "place-north", updated.Config["stop_id"], "Expected config to be stored")
|
||||
|
||||
// Config is write-only: it must not come back out in the PATCH response.
|
||||
assert.NotContains(t, rr.Body.String(), "secret-token", "PATCH response leaked app config")
|
||||
|
||||
// ...nor in the GET payload.
|
||||
req = newAPIRequest("GET", fmt.Sprintf("/v0/devices/%s/installations/%s", deviceID, installID), apiKey, nil)
|
||||
rr = httptest.NewRecorder()
|
||||
s.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusOK, rr.Code, "GET body: %s", rr.Body.String())
|
||||
assert.NotContains(t, rr.Body.String(), "secret-token", "GET response leaked app config")
|
||||
|
||||
// The non-secret render fields are readable, so a client can diff them.
|
||||
var payload AppPayload
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&payload), "Failed to decode GET response")
|
||||
assert.True(t, payload.AutoPin, "GET autoPin: expected true")
|
||||
if assert.NotNil(t, payload.ColorFilter, "GET colorFilter: expected dimmed") {
|
||||
assert.Equal(t, data.ColorFilterDimmed, *payload.ColorFilter)
|
||||
}
|
||||
|
||||
// "auto" and "inherit" clear back to the device default.
|
||||
auto := "auto"
|
||||
inherit := "inherit"
|
||||
clearUpdate := InstallationUpdate{ShowFullAnimation: &auto, ColorFilter: &inherit}
|
||||
body, err = json.Marshal(clearUpdate)
|
||||
require.NoError(t, err)
|
||||
req = newAPIRequest("PATCH", fmt.Sprintf("/v0/devices/%s/installations/%s", deviceID, installID), apiKey, body)
|
||||
rr = httptest.NewRecorder()
|
||||
s.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusOK, rr.Code, "clear body: %s", rr.Body.String())
|
||||
cleared, err := gorm.G[data.App](s.DB).Where("iname = ?", installID).First(context.Background())
|
||||
require.NoError(t, err, "Failed to fetch cleared app state")
|
||||
assert.Nil(t, cleared.ShowFullAnimation, "Expected showFullAnimation cleared")
|
||||
assert.Nil(t, cleared.ColorFilter, "Expected colorFilter cleared")
|
||||
|
||||
// An unknown filter is rejected rather than stored -- and rejected before
|
||||
// the valid half of the same request is applied.
|
||||
bogus := "chartreuse"
|
||||
pinned := true
|
||||
body, err = json.Marshal(InstallationUpdate{ColorFilter: &bogus, Pinned: &pinned})
|
||||
require.NoError(t, err)
|
||||
req = newAPIRequest("PATCH", fmt.Sprintf("/v0/devices/%s/installations/%s", deviceID, installID), apiKey, body)
|
||||
rr = httptest.NewRecorder()
|
||||
s.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusBadRequest, rr.Code, "bogus colorFilter")
|
||||
|
||||
device, err := gorm.G[data.Device](s.DB).Where("id = ?", deviceID).First(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, device.PinnedApp, "rejected request must not have pinned the app")
|
||||
}
|
||||
|
||||
func TestHandlePatchInstallationDisableRemovesRenders(t *testing.T) {
|
||||
s := newTestServerAPI(t)
|
||||
apiKey := "test_api_key"
|
||||
deviceID := "testdevice"
|
||||
installID := "disableapp"
|
||||
|
||||
app := data.App{
|
||||
DeviceID: deviceID,
|
||||
Iname: installID,
|
||||
Name: "Disable App",
|
||||
UInterval: 10,
|
||||
DisplayTime: 10,
|
||||
Enabled: true,
|
||||
Order: 0,
|
||||
}
|
||||
require.NoError(t, gorm.G[data.App](s.DB).Create(context.Background(), &app))
|
||||
|
||||
webpDir := filepath.Join(s.DataDir, "webp", deviceID)
|
||||
pushedDir := filepath.Join(webpDir, "pushed")
|
||||
require.NoError(t, os.MkdirAll(pushedDir, 0755))
|
||||
rendered := filepath.Join(webpDir, fmt.Sprintf("1700000000-%s.webp", installID))
|
||||
pushed := filepath.Join(pushedDir, installID+".webp")
|
||||
require.NoError(t, os.WriteFile(rendered, []byte("render"), 0644))
|
||||
require.NoError(t, os.WriteFile(pushed, []byte("push"), 0644))
|
||||
|
||||
disabled := false
|
||||
body, err := json.Marshal(InstallationUpdate{Enabled: &disabled})
|
||||
require.NoError(t, err)
|
||||
req := newAPIRequest("PATCH", fmt.Sprintf("/v0/devices/%s/installations/%s", deviceID, installID), apiKey, body)
|
||||
rr := httptest.NewRecorder()
|
||||
s.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusOK, rr.Code, "PATCH body: %s", rr.Body.String())
|
||||
|
||||
updated, err := gorm.G[data.App](s.DB).Where("iname = ?", installID).First(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.False(t, updated.Enabled, "app should be disabled")
|
||||
|
||||
// Cleanup runs after the commit, but still within the request.
|
||||
assert.NoFileExists(t, rendered, "rendered webp should be removed on disable")
|
||||
assert.NoFileExists(t, pushed, "pushed webp should be removed on disable")
|
||||
}
|
||||
|
||||
func TestRemoveAppRendersRejectsUnsafeIname(t *testing.T) {
|
||||
s := newTestServerAPI(t)
|
||||
deviceID := "testdevice"
|
||||
|
||||
webpDir, err := s.ensureDeviceImageDir(deviceID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(webpDir, "pushed"), 0755))
|
||||
|
||||
// A file the cleanup has no business touching: it sits beside the device
|
||||
// directory, where another device's renders live. Joining the pushed-image
|
||||
// path with a traversing iname lands exactly here.
|
||||
victim := filepath.Join(s.DataDir, "webp", "victim.webp")
|
||||
require.NoError(t, os.WriteFile(victim, []byte("victim"), 0644))
|
||||
|
||||
// An unrelated render belonging to another installation on this device.
|
||||
other := filepath.Join(webpDir, "1700000000-other.webp")
|
||||
require.NoError(t, os.WriteFile(other, []byte("other"), 0644))
|
||||
|
||||
// Traversal: an iname like this can only arrive via an imported config,
|
||||
// which stores the uploaded value verbatim.
|
||||
s.removeAppRenders(deviceID, "../../victim")
|
||||
assert.FileExists(t, victim, "traversal iname must not reach outside the device directory")
|
||||
|
||||
// Glob metacharacters must not widen the match past this app's files.
|
||||
s.removeAppRenders(deviceID, "*")
|
||||
assert.FileExists(t, other, "glob metacharacter in iname must not match other installations")
|
||||
|
||||
assert.FileExists(t, victim, "unrelated file must survive both attempts")
|
||||
}
|
||||
|
||||
@@ -307,6 +307,17 @@ func (s *Server) getColorFilterChoices() []ColorFilterOption {
|
||||
}
|
||||
}
|
||||
|
||||
// isValidColorFilter reports whether v names a filter the renderer knows,
|
||||
// using the same list the app config page offers.
|
||||
func (s *Server) isValidColorFilter(v string) bool {
|
||||
for _, c := range s.getColorFilterChoices() {
|
||||
if c.Value == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// generateSecureToken generates a URL-safe, base64 encoded, securely random string.
|
||||
// This is used for generating API keys and device IDs.
|
||||
func generateSecureToken(length int) (string, error) {
|
||||
|
||||
Reference in New Issue
Block a user