diff --git a/README.md b/README.md index 9aac2e7..504cc53 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,16 @@ This is a simple reverse proxy which adds authentication token to requests to do Also provides a simple web interface to view camera snapshots and open doors +The home page lists your own intercoms and, when the operator exposes +`/rest/v1/places/{placeId}/screen-sections`, cameras of neighboring entrances. +Neighboring cameras are viewable only with an active Pro subscription; otherwise +their cards say so. Cameras are matched by camera/group IDs rather than list +order, and the "Open door" button is shown only for your own access controls +(taken from `/rest/v1/places/{placeId}/accesscontrols` with `allowOpen`). If the +extra endpoints fail or return 404, the basic camera list is still rendered. +Every card comes with its own Home Assistant snippet; merge them under a single +`camera:` / `rest_command:` section. + ## Run in Docker Find available docker images here: https://github.com/moleus/domru/pkgs/container/domru Please, don't use `latest` tag, because new update can break your setup @@ -97,6 +107,8 @@ All other requests are forwarded to Domru API. A few of them: | Endpoint | Method | Description | |-----------------------------------------------------------------------------|--------|--------------------| | `/rest/v1/forpost/cameras` | GET | Get cameras list | +| `/rest/v1/places/{placeId}/screen-sections` | GET | Additional camera sections and subscription access | +| `/rest/v1/places/{placeId}/accesscontrols/{accessControlId}/snapshots` | GET | Camera snapshot, including authorized neighboring entrances | | `/rest/v1/places/{placeId}/accesscontrols/{accessControlId}/actions` | POST | Open door | | `/rest/v1/subscribers/profiles/finances` | GET | Get finances | | `/rest/v1/subscribers/profiles` | GET | Get profile info | diff --git a/cmd/controllers/common.go b/cmd/controllers/common.go index c51d6dd..8fe04b1 100644 --- a/cmd/controllers/common.go +++ b/cmd/controllers/common.go @@ -9,7 +9,6 @@ import ( "github.com/moleus/domru/pkg/auth" "github.com/moleus/domru/pkg/domru" - "github.com/moleus/domru/pkg/domru/constants" "github.com/moleus/domru/pkg/domru/models" "github.com/moleus/domru/pkg/homeassistant" ) @@ -57,11 +56,7 @@ func (h *Handler) renderTemplate(w http.ResponseWriter, templateName string, dat } func getTemplateFunctions() template.FuncMap { - return template.FuncMap{ - "getSnapshotUrl": constants.GetSnapshotUrl, - "getOpenDoorUrl": constants.GetOpenDoorUrl, - "getCameraStreamUrl": constants.GetCameraStreamUrl, - } + return template.FuncMap{} } func (h *Handler) determineBaseURL(r *http.Request) string { var scheme string diff --git a/cmd/controllers/home.go b/cmd/controllers/home.go index 7a00265..577cee9 100644 --- a/cmd/controllers/home.go +++ b/cmd/controllers/home.go @@ -2,11 +2,14 @@ package controllers import ( errors2 "errors" + "fmt" "net/http" "strings" "github.com/moleus/domru/cmd/models" "github.com/moleus/domru/pkg/authorizedhttp" + "github.com/moleus/domru/pkg/domru/helpers" + domrumodels "github.com/moleus/domru/pkg/domru/models" ) func (h *Handler) HomeHandler(w http.ResponseWriter, r *http.Request) { @@ -52,10 +55,43 @@ func (h *Handler) prepareHomePageData(r *http.Request) (models.HomePageData, err } } - errorsMessage := strings.Join(errors, "\n") - data.BaseURL = h.determineBaseURL(r) - data.LoginError = errorsMessage + sections := make(map[int]domrumodels.ScreenSectionsResponse) + controlsByPlace := make(map[int][]domrumodels.AccessControl) + for i, item := range data.Places.Data { + placeID := item.Place.ID + controls, exists := controlsByPlace[placeID] + if !exists { + result, err := h.domruAPI.RequestAccessControls(placeID) + controls = result.Data + if err != nil { + // The legacy embedded list can incorrectly advertise door access + // for paid neighboring cameras. Keep video metadata only on failure. + controls = append([]domrumodels.AccessControl(nil), item.Place.AccessControls...) + for j := range controls { + controls[j].AllowOpen = false + } + errors = append(errors, fmt.Sprintf("Не удалось проверить доступ к домофонам адреса %d: %v", placeID, err)) + } + controlsByPlace[placeID] = controls + } + data.Places.Data[i].Place.AccessControls = controls + if _, exists := sections[placeID]; exists { + continue + } + result, err := h.domruAPI.RequestScreenSections(placeID) + sections[placeID] = result + if err != nil { + // Older operators may not expose this optional endpoint. + var upstreamErr *helpers.UpstreamError + if errors2.As(err, &upstreamErr) && upstreamErr.StatusCode == http.StatusNotFound { + continue + } + errors = append(errors, fmt.Sprintf("Не удалось загрузить дополнительные камеры адреса %d: %v", placeID, err)) + } + } + data.CameraCards = buildCameraCards(data.BaseURL, data.Places, data.Cameras, sections) + data.LoginError = strings.Join(errors, "\n") return data, nil } diff --git a/cmd/controllers/home_cameras.go b/cmd/controllers/home_cameras.go new file mode 100644 index 0000000..45f75fb --- /dev/null +++ b/cmd/controllers/home_cameras.go @@ -0,0 +1,138 @@ +package controllers + +import ( + "fmt" + "sort" + "strconv" + + "github.com/moleus/domru/cmd/models" + "github.com/moleus/domru/pkg/domru/constants" + domrumodels "github.com/moleus/domru/pkg/domru/models" +) + +func cameraID(value interface{}) int { + id, err := strconv.Atoi(fmt.Sprint(value)) + if err != nil || id <= 0 { + return 0 + } + return id +} + +func accessControlCameraID(ac domrumodels.AccessControl, cameras []domrumodels.Camera) int { + if id := cameraID(ac.ExternalCameraId); id != 0 { + return id + } + for _, camera := range cameras { + for _, group := range camera.ParentGroups { + if strconv.Itoa(group.ID) == ac.ForpostGroupId { + return camera.ID + } + } + } + return 0 +} + +func setCameraMedia(card *models.CameraCard, baseURL, snapshotURL string, allowVideo, allowSnapshot bool) { + if allowVideo && card.ID > 0 { + card.StreamURL = constants.GetCameraStreamUrl(baseURL, card.ID) + } + if allowSnapshot { + card.SnapshotURL = snapshotURL + } + switch { + case card.StreamURL != "": + card.Status = "Доступна" + case card.SnapshotURL != "": + card.Status = "Доступен снимок; видеопоток недоступен" + default: + card.Status = "Просмотр недоступен" + } +} + +func buildCameraCards(baseURL string, places domrumodels.PlacesResponse, cameras domrumodels.CamerasResponse, sections map[int]domrumodels.ScreenSectionsResponse) []models.CameraCard { + var cards []models.CameraCard + seenCameras := make(map[int]bool) + seenControls := make(map[[2]int]bool) + sectionControls := make(map[[2]int]bool) + for placeID, response := range sections { + for _, section := range response.Sections { + if section.Type == "ACCESS_CONTROL_CAMERA" { + for _, camera := range section.Entities { + sectionControls[[2]int{placeID, camera.AccessControlID}] = true + } + } + } + } + + for _, item := range places.Data { + place := item.Place + for _, ac := range place.AccessControls { + key := [2]int{place.ID, ac.ID} + if seenControls[key] || sectionControls[key] { + continue + } + seenControls[key] = true + id := accessControlCameraID(ac, cameras.Data) + card := models.CameraCard{ + ID: id, Name: ac.Name, Section: "Мои домофоны", + ConfigName: fmt.Sprintf("domofon_%d_%d", place.ID, ac.ID), + } + setCameraMedia(&card, baseURL, constants.GetSnapshotUrl(baseURL, place.ID, ac.ID), + ac.AllowVideo, ac.AllowSlideshow || ac.PreviewAvailable) + if ac.AllowOpen { + card.OpenDoorURL = constants.GetOpenDoorUrl(baseURL, place.ID, ac.ID) + } + cards = append(cards, card) + seenCameras[id] = true + } + } + + for _, item := range places.Data { + placeID := item.Place.ID + placeSections := append([]domrumodels.ScreenSection(nil), sections[placeID].Sections...) + sort.SliceStable(placeSections, func(i, j int) bool { return placeSections[i].Order < placeSections[j].Order }) + for _, section := range placeSections { + if section.Type != "ACCESS_CONTROL_CAMERA" { + continue + } + for _, camera := range section.Entities { + key := [2]int{placeID, camera.AccessControlID} + if camera.AccessControlID <= 0 || seenControls[key] { + continue + } + seenControls[key] = true + id := cameraID(camera.ExternalCameraID) + if id > 0 && seenCameras[id] { + continue + } + card := models.CameraCard{ + ID: id, Name: camera.Name, Section: "Соседний подъезд", + ConfigName: fmt.Sprintf("domofon_%d_%d", placeID, camera.AccessControlID), + Status: "Требуется подписка Pro", + } + if camera.ServiceActivated { + snapshotURL := fmt.Sprintf("%s/rest/v1/places/%d/accesscontrols/%d/snapshots?width=320&height=180", baseURL, placeID, camera.AccessControlID) + setCameraMedia(&card, baseURL, snapshotURL, camera.AllowVideo, camera.AllowSlideshow || camera.PreviewAvailable) + } + cards = append(cards, card) + seenCameras[id] = true + } + } + } + + // Keep standalone account cameras that are not associated with a door. + for _, camera := range cameras.Data { + if camera.ID <= 0 || seenCameras[camera.ID] { + continue + } + card := models.CameraCard{ + ID: camera.ID, Name: camera.Name, Section: "Другие камеры", + ConfigName: fmt.Sprintf("domru_camera_%d", camera.ID), + } + snapshotURL := fmt.Sprintf("%s/rest/v1/forpost/cameras/%d/snapshots?width=320&height=180", baseURL, camera.ID) + setCameraMedia(&card, baseURL, snapshotURL, camera.IsActive == 1, camera.IsActive == 1) + cards = append(cards, card) + seenCameras[camera.ID] = true + } + return cards +} diff --git a/cmd/controllers/home_cameras_test.go b/cmd/controllers/home_cameras_test.go new file mode 100644 index 0000000..7a41552 --- /dev/null +++ b/cmd/controllers/home_cameras_test.go @@ -0,0 +1,238 @@ +package controllers + +import ( + "bytes" + "errors" + "html/template" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + pagemodels "github.com/moleus/domru/cmd/models" + "github.com/moleus/domru/pkg/domru" + domrumodels "github.com/moleus/domru/pkg/domru/models" + "github.com/stretchr/testify/require" +) + +const cameraTestBase = "http://localhost:8080" + +func TestCameraCardsMatchAcrossPlacesAndKeepStandaloneCameras(t *testing.T) { + places := domrumodels.PlacesResponse{Data: []domrumodels.Data{ + {Place: domrumodels.Place{ID: 10, AccessControls: []domrumodels.AccessControl{ + {ID: 11, Name: "First door", ForpostGroupId: "101", AllowOpen: true, AllowVideo: true, PreviewAvailable: true}, + {ID: 12, Name: "Unmatched door", AllowVideo: true}, + }}}, + {Place: domrumodels.Place{ID: 20, AccessControls: []domrumodels.AccessControl{ + {ID: 21, Name: "Second door", ExternalCameraId: "202", AllowVideo: true, AllowSlideshow: true}, + }}}, + }} + cameras := domrumodels.CamerasResponse{Data: []domrumodels.Camera{ + {ID: 202, IsActive: 1}, + {ID: 303, Name: "Standalone", IsActive: 1}, + {ID: 201, IsActive: 1, ParentGroups: []domrumodels.ParentGroup{{ID: 101}}}, + }} + cards := buildCameraCards(cameraTestBase, places, cameras, nil) + require.Len(t, cards, 4) + require.Equal(t, 201, cards[0].ID) + require.Equal(t, cameraTestBase+"/stream/201", cards[0].StreamURL) + require.Equal(t, cameraTestBase+"/rest/v1/places/10/accesscontrols/11/actions", cards[0].OpenDoorURL) + require.Zero(t, cards[1].ID, "an unmatched door must not borrow a camera by array position") + require.Empty(t, cards[1].StreamURL) + require.Equal(t, 202, cards[2].ID) + require.Empty(t, cards[2].OpenDoorURL) + require.Equal(t, 303, cards[3].ID) + require.Contains(t, cards[3].SnapshotURL, "/forpost/cameras/303/snapshots") + require.Empty(t, cards[3].OpenDoorURL) +} + +func TestNeighborCameraEntitlements(t *testing.T) { + for _, tc := range []struct { + name string + activated bool + video bool + preview bool + id string + wantStream bool + wantSnapshot bool + }{ + {name: "unpaid with advertised capabilities", video: true, preview: true, id: "301"}, + {name: "unpaid null ID", video: true, preview: true}, + {name: "paid", activated: true, video: true, preview: true, id: "301", wantStream: true, wantSnapshot: true}, + {name: "paid without video permission", activated: true, preview: true, id: "301", wantSnapshot: true}, + {name: "paid missing camera ID", activated: true, video: true, preview: true, wantSnapshot: true}, + {name: "paid invalid camera ID", activated: true, video: true, id: "../actions"}, + {name: "paid without preview", activated: true, video: true, id: "301", wantStream: true}, + } { + t.Run(tc.name, func(t *testing.T) { + places := domrumodels.PlacesResponse{Data: []domrumodels.Data{{Place: domrumodels.Place{ID: 10}}}} + sections := map[int]domrumodels.ScreenSectionsResponse{10: {Sections: []domrumodels.ScreenSection{ + {Type: "ACCESS_CONTROL_CAMERA", Title: "Соседний подъезд", Entities: []domrumodels.AccessControlCamera{ + {AccessControlID: 31, Name: "Neighbor", ServiceActivated: tc.activated, AllowVideo: tc.video, PreviewAvailable: tc.preview, ExternalCameraID: tc.id}, + }}, + }}} + cards := buildCameraCards(cameraTestBase, places, domrumodels.CamerasResponse{}, sections) + require.Len(t, cards, 1) + require.Equal(t, tc.wantStream, cards[0].StreamURL != "") + require.Equal(t, tc.wantSnapshot, cards[0].SnapshotURL != "") + require.Empty(t, cards[0].OpenDoorURL, "a neighboring camera never grants door access") + if !tc.activated { + require.Equal(t, "Требуется подписка Pro", cards[0].Status) + } + if tc.wantStream { + require.Equal(t, cameraTestBase+"/stream/301", cards[0].StreamURL) + } + if tc.wantSnapshot { + require.Contains(t, cards[0].SnapshotURL, "/places/10/accesscontrols/31/snapshots?") + } + }) + } +} + +func TestCameraCardsDeduplicateSourcesAndIgnoreOtherSections(t *testing.T) { + place := domrumodels.Data{Place: domrumodels.Place{ID: 10, AccessControls: []domrumodels.AccessControl{ + {ID: 11, ExternalCameraId: float64(201), AllowVideo: true}, + }}} + sections := map[int]domrumodels.ScreenSectionsResponse{10: {Sections: []domrumodels.ScreenSection{ + {Type: "OTHER", Entities: []domrumodels.AccessControlCamera{{AccessControlID: 99}}}, + {Type: "ACCESS_CONTROL_CAMERA", Entities: []domrumodels.AccessControlCamera{ + {AccessControlID: 11, ExternalCameraID: "201", ServiceActivated: true, AllowVideo: true}, + {AccessControlID: 31, ExternalCameraID: "301", ServiceActivated: true, AllowVideo: true}, + {AccessControlID: 31, ExternalCameraID: "301", ServiceActivated: true, AllowVideo: true}, + }}, + }}} + cards := buildCameraCards(cameraTestBase, domrumodels.PlacesResponse{Data: []domrumodels.Data{place, place}}, + domrumodels.CamerasResponse{Data: []domrumodels.Camera{{ID: 201}, {ID: 301}}}, sections) + require.Len(t, cards, 2) + require.Equal(t, 201, cards[0].ID) + require.Equal(t, 301, cards[1].ID) +} + +type cameraHTTPClient func(*http.Request) (*http.Response, error) + +func (f cameraHTTPClient) Do(r *http.Request) (*http.Response, error) { return f(r) } + +func TestHomePageLoadsSectionsAndPreservesCamerasOnFailure(t *testing.T) { + for _, status := range []int{http.StatusOK, http.StatusNotFound, http.StatusForbidden, http.StatusServiceUnavailable} { + t.Run(http.StatusText(status), func(t *testing.T) { + var sectionCalls int + client := cameraHTTPClient(func(r *http.Request) (*http.Response, error) { + require.Equal(t, http.MethodGet, r.Method) + code := http.StatusOK + var body string + switch r.URL.Path { + case "/rest/v1/forpost/cameras": + body = `{"data":[{"ID":201,"IsActive":1,"ParentGroups":[{"ID":101}]}]}` + case "/rest/v1/subscriberplaces": + body = `{"data":[{"place":{"id":10,"accessControls":[{"id":11,"name":"Own","forpostGroupId":"101","allowOpen":true,"allowVideo":true,"previewAvailable":true}]}},{"place":{"id":20}}]}` + case "/rest/v1/places/10/accesscontrols": + body = `{"data":[{"id":11,"name":"Own","externalCameraId":"201","allowOpen":true,"allowVideo":true,"previewAvailable":true}]}` + case "/rest/v1/places/20/accesscontrols": + body = `{"data":[]}` + case "/rest/v1/subscribers/profiles": + body = `{}` + case "/rest/v1/places/10/screen-sections", "/rest/v1/places/20/screen-sections": + sectionCalls++ + code = status + body = `{"errorCode":"UNAVAILABLE"}` + if status == http.StatusOK { + body = `{"sections":[{"type":"ACCESS_CONTROL_CAMERA","title":"Соседний подъезд","entities":[{"accessControlId":31,"name":"Neighbor","serviceActivated":true,"allowVideo":true,"previewAvailable":true,"externalCameraId":"301"},{"accessControlId":32,"name":"Locked","serviceActivated":false,"allowVideo":true,"externalCameraId":null}]}]}` + } + default: + return nil, errors.New("unexpected endpoint: " + r.URL.Path) + } + return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + }) + handler := &Handler{domruAPI: domru.NewDomruAPI(client)} + data, err := handler.prepareHomePageData(httptest.NewRequest(http.MethodGet, cameraTestBase+"/pages/home.html", nil)) + require.NoError(t, err) + require.Equal(t, 2, sectionCalls, "load sections for every account place") + require.NotEmpty(t, data.CameraCards) + require.Equal(t, 201, data.CameraCards[0].ID) + require.NotEmpty(t, data.CameraCards[0].StreamURL) + if status == http.StatusOK { + require.Greater(t, len(data.CameraCards), 1) + require.Equal(t, 301, data.CameraCards[1].ID) + } + if status == http.StatusOK || status == http.StatusNotFound { + require.Empty(t, data.LoginError) + } else { + require.NotEmpty(t, data.LoginError) + } + html := renderCameraTestPage(t, data) + require.Contains(t, html, "/stream/201", "partial errors must not hide working cameras") + }) + } +} + +func renderCameraTestPage(t *testing.T, data pagemodels.HomePageData) string { + t.Helper() + source, err := os.ReadFile("../../templates/home.html.tmpl") + require.NoError(t, err) + tmpl, err := template.New("home").Funcs(getTemplateFunctions()).Parse(string(source)) + require.NoError(t, err) + var buffer bytes.Buffer + require.NoError(t, tmpl.Execute(&buffer, data)) + return buffer.String() +} + +func TestHomeTemplateMediaPermissionsAndEscaping(t *testing.T) { + locked := pagemodels.CameraCard{Name: "", Status: "Требуется подписка Pro"} + html := renderCameraTestPage(t, pagemodels.HomePageData{CameraCards: []pagemodels.CameraCard{locked}}) + require.Contains(t, html, "Требуется подписка Pro") + require.NotContains(t, html, "") + require.NotContains(t, html, "") + require.Contains(t, html, "<script>") + + html = renderCameraTestPage(t, pagemodels.HomePageData{CameraCards: []pagemodels.CameraCard{ + {ID: 301, Name: "Neighbor", ConfigName: "domofon_10_31", SnapshotURL: cameraTestBase + "/snapshot", StreamURL: cameraTestBase + "/stream/301"}, + }}) + require.Contains(t, html, "
+

Камеры аккаунта

{{ if .LoginError }} -
- {{ .LoginError }} -
- {{ else }} -
-
- {{ if .Phone }} -
-
Номер телефона:
-
+{{ .Phone }}
-
- {{ end }} - {{ range $_, $placeEl := .Places.Data }} - {{ range $index, $ac := $placeEl.Place.AccessControls }} - {{$snapshotUrl := getSnapshotUrl $.BaseURL $placeEl.Place.ID $ac.ID }} - {{$streamUrl := "Camera for this index not found ;("}} - {{$cameraId := ""}} - {{ if lt $index (len $.Cameras.Data) }} - {{ with (index $.Cameras.Data $index) }} - {{$streamUrl = getCameraStreamUrl $.BaseURL .ID }} - {{$cameraId = printf "№ %d" .ID }} - {{ end }} - {{ end }} - {{$openDoorUrl := getOpenDoorUrl $.BaseURL $placeEl.Place.ID $ac.ID }} - -
-
Камера:
-
{{ $cameraId }}
-
-
-
Адрес:
-
- {{ $ac.Name }} - + + {{ end }} + {{ if .Phone }}

Номер телефона: +{{ .Phone }}

{{ end }} + {{ range .CameraCards }} +
+

{{ .Section }} — {{ .Name }}

+
+
+
+
Камера:
+
{{ if .ID }}№ {{ .ID }}{{ else }}Идентификатор пока недоступен{{ end }}
-
-
-
Видеопоток:
-
- {{ $streamUrl }} +
+
Адрес / название:
+
+ {{ .Name }} + {{ if .OpenDoorURL }}{{ end }} +
-
-
-
-
- Камера +
+
Доступ:
+
{{ .Status }}
+ {{ if .StreamURL }} +
+
Видеопоток:
+ +
+ {{ end }} + {{ if .SnapshotURL }} +
+
Снимок:
+ +
+
+
+
{{ .Name }}
+
+ {{ end }}
-
-
-
-
-# Home Assistant configuration:
-rest_command:
-    domru_open_door:
-        url: {{ $openDoorUrl }}
+        
+ {{ if or .StreamURL .SnapshotURL .OpenDoorURL }} +
# Home Assistant configuration:
+{{ if .OpenDoorURL }}rest_command:
+    {{ .ConfigName }}_open_door:
+        url: {{ .OpenDoorURL }}
         method: post
         headers:
             accept: "application/json"
-        content_type:  'application/json; charset=utf-8'
+        content_type: 'application/json; charset=utf-8'
         payload: '{"name":"accessControlOpen"}'
-
+{{ end }}{{ if or .StreamURL .SnapshotURL }}
 camera:
     - platform: generic
-        name: domofon
-        still_image_url: {{ $snapshotUrl }}
-        stream_source: {{ $streamUrl }}
-                        
-
-
- {{ end }} - {{ end }} -
-
+ name: {{ .ConfigName }} +{{ if .SnapshotURL }} still_image_url: {{ .SnapshotURL }} +{{ end }}{{ if .StreamURL }} stream_source: {{ .StreamURL }} +{{ end }}{{ end }} + {{ end }} +
+ {{ else }} +

Камеры не найдены.

{{ end }}