0.1.38 gateway
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// displayName is what an event summary calls somebody or something that did not say.
|
||||
// A sentence reading " signed in on " is worse than one naming the gap.
|
||||
func displayName(value string) string {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
return "an unnamed device"
|
||||
}
|
||||
|
||||
// recordLogin writes one attempt to the durable history.
|
||||
//
|
||||
// Detached from the request context and fire-and-forget, for the reason every audit write
|
||||
// on a request path is: a sign-in that succeeded must not be undone because the history
|
||||
// row could not be written, and a sign-in being refused is already being refused. The
|
||||
// address and the build are filled in here rather than by each caller, so an attempt
|
||||
// recorded from a new route cannot quietly omit them.
|
||||
func (s *Server) recordLogin(r *http.Request, event store.LoginEvent) {
|
||||
if s.store == nil {
|
||||
return
|
||||
}
|
||||
if event.IPAddress == "" {
|
||||
event.IPAddress = requestClientIP(r)
|
||||
}
|
||||
if event.ClientVersion == "" {
|
||||
event.ClientVersion = clientVersion(r)
|
||||
}
|
||||
if event.ClientProtocol == "" {
|
||||
event.ClientProtocol = clientProtocol(r)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second)
|
||||
go func() {
|
||||
defer cancel()
|
||||
if err := s.store.RecordLogin(ctx, event); err != nil {
|
||||
s.log.Warn("login not recorded",
|
||||
"device_id", event.DeviceID, "success", event.Success, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// loginFilterFrom reads the console's filter bar off the query string.
|
||||
//
|
||||
// Every control is optional and an absent one means "any", which is what lets the page
|
||||
// combine them freely — user and device and address and a date range — without the server
|
||||
// needing a case per combination. Dates arrive as RFC3339 or as a plain YYYY-MM-DD, and a
|
||||
// plain date is read in the *household's* zone: an operator asking for "13 August" means
|
||||
// their own day, and reading it as UTC would silently shift the window by twelve hours.
|
||||
func (s *Server) loginFilterFrom(r *http.Request) store.LoginFilter {
|
||||
query := r.URL.Query()
|
||||
filter := store.LoginFilter{
|
||||
EmbyUserID: strings.TrimSpace(query.Get("user")),
|
||||
DeviceID: strings.TrimSpace(query.Get("device")),
|
||||
IPAddress: strings.TrimSpace(query.Get("ip")),
|
||||
Query: strings.TrimSpace(query.Get("q")),
|
||||
Outcome: strings.TrimSpace(query.Get("outcome")),
|
||||
Method: strings.TrimSpace(query.Get("method")),
|
||||
Limit: queryInt(r, "limit", 100, 500),
|
||||
Offset: queryInt(r, "offset", 0, 100000),
|
||||
}
|
||||
filter.From = s.parseFilterDate(query.Get("from"), false)
|
||||
filter.To = s.parseFilterDate(query.Get("to"), true)
|
||||
if days := queryInt(r, "days", 0, 365); days > 0 && filter.From.IsZero() {
|
||||
filter.From = time.Now().Add(-time.Duration(days) * 24 * time.Hour)
|
||||
}
|
||||
return filter
|
||||
}
|
||||
|
||||
// parseFilterDate reads one end of a range. endOfDay pushes a plain date to the following
|
||||
// midnight, because "to: 13 August" means through the end of the 13th — the alternative
|
||||
// silently excludes everything that happened on the day the operator asked about.
|
||||
func (s *Server) parseFilterDate(raw string, endOfDay bool) time.Time {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
return parsed
|
||||
}
|
||||
location := s.cfg.SonarrLocation
|
||||
if location == nil {
|
||||
location = time.UTC
|
||||
}
|
||||
parsed, err := time.ParseInLocation("2006-01-02", raw, location)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
if endOfDay {
|
||||
return parsed.AddDate(0, 0, 1)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// zoneName is the household's timezone, which every day-grouping query needs. Postgres is
|
||||
// asked to do the grouping in it rather than the console doing it in the browser's zone,
|
||||
// for the reason store.LoginDays gives.
|
||||
func (s *Server) zoneName() string {
|
||||
if s.cfg.SonarrLocation != nil {
|
||||
return s.cfg.SonarrLocation.String()
|
||||
}
|
||||
return "UTC"
|
||||
}
|
||||
|
||||
type adminLoginsResponse struct {
|
||||
Events []store.LoginEvent `json:"events"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Totals store.LoginTotals `json:"totals"`
|
||||
Days []store.LoginDay `json:"days"`
|
||||
Addresses []store.LoginAddress `json:"addresses"`
|
||||
Users []store.KnownUser `json:"users"`
|
||||
Retention int `json:"retentionDays"`
|
||||
Zone string `json:"timezone"`
|
||||
}
|
||||
|
||||
// handleAdminLogins is the login history page: the filtered log, the totals over the same
|
||||
// filter, the daily shape and where the attempts came from, in one response.
|
||||
//
|
||||
// One response rather than four routes because they are four views of one filter, and a
|
||||
// page that fetched them separately could show a chart of one window beside a table of
|
||||
// another the moment a filter changed between requests.
|
||||
func (s *Server) handleAdminLogins(w http.ResponseWriter, r *http.Request) {
|
||||
filter := s.loginFilterFrom(r)
|
||||
page, err := s.store.LoginEvents(r.Context(), filter)
|
||||
if err != nil {
|
||||
s.log.Error("login history failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not read the login history")
|
||||
return
|
||||
}
|
||||
response := adminLoginsResponse{
|
||||
Events: page.Events, Total: page.Total, Limit: page.Limit, Offset: page.Offset,
|
||||
Retention: int(store.LoginRetention / (24 * time.Hour)), Zone: s.zoneName(),
|
||||
Days: []store.LoginDay{}, Addresses: []store.LoginAddress{},
|
||||
Users: []store.KnownUser{},
|
||||
}
|
||||
// The three summaries are decoration on the table: a failure in any of them costs a
|
||||
// chart, never the history the operator opened the page for.
|
||||
if totals, err := s.store.LoginTotals(r.Context(), filter); err == nil {
|
||||
response.Totals = totals
|
||||
}
|
||||
if days, err := s.store.LoginDays(r.Context(), filter, s.zoneName()); err == nil {
|
||||
response.Days = days
|
||||
}
|
||||
if addresses, err := s.store.LoginAddresses(r.Context(), filter, 25); err == nil {
|
||||
response.Addresses = addresses
|
||||
}
|
||||
if users, err := s.store.KnownUsers(r.Context()); err == nil {
|
||||
response.Users = users
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
type adminLoginDevicesResponse struct {
|
||||
Devices []store.LoginDeviceSummary `json:"devices"`
|
||||
Totals store.LoginTotals `json:"totals"`
|
||||
Users []store.KnownUser `json:"users"`
|
||||
Zone string `json:"timezone"`
|
||||
Retention int `json:"retentionDays"`
|
||||
}
|
||||
|
||||
// handleAdminLoginDevices is the devices table, built from the history rather than from
|
||||
// the session list — see store.LoginDeviceSummaries for why a removed television still
|
||||
// belongs on it.
|
||||
func (s *Server) handleAdminLoginDevices(w http.ResponseWriter, r *http.Request) {
|
||||
filter := s.loginFilterFrom(r)
|
||||
devices, err := s.store.LoginDeviceSummaries(r.Context(), filter, s.zoneName())
|
||||
if err != nil {
|
||||
s.log.Error("login device summary failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not summarise devices")
|
||||
return
|
||||
}
|
||||
response := adminLoginDevicesResponse{
|
||||
Devices: devices, Zone: s.zoneName(), Users: []store.KnownUser{},
|
||||
Retention: int(store.LoginRetention / (24 * time.Hour)),
|
||||
}
|
||||
if totals, err := s.store.LoginTotals(r.Context(), filter); err == nil {
|
||||
response.Totals = totals
|
||||
}
|
||||
if users, err := s.store.KnownUsers(r.Context()); err == nil {
|
||||
response.Users = users
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
type adminDeviceDetailResponse struct {
|
||||
DeviceID string `json:"deviceId"`
|
||||
Summary *store.LoginDeviceSummary `json:"summary,omitempty"`
|
||||
Events []store.LoginEvent `json:"events"`
|
||||
Total int `json:"total"`
|
||||
Days []store.LoginDay `json:"days"`
|
||||
Addresses []store.LoginAddress `json:"addresses"`
|
||||
Versions []store.DeviceVersion `json:"versions"`
|
||||
Zone string `json:"timezone"`
|
||||
}
|
||||
|
||||
// handleAdminDeviceDetail answers the question the whole feature exists for: how many
|
||||
// times did this television connect, at what times, and from which addresses.
|
||||
func (s *Server) handleAdminDeviceDetail(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := strings.TrimSpace(r.PathValue("deviceID"))
|
||||
if deviceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "device id is required")
|
||||
return
|
||||
}
|
||||
filter := s.loginFilterFrom(r)
|
||||
// The path wins over the query string: this route is *about* one device, and a filter
|
||||
// naming another would render a page describing something other than its own URL.
|
||||
filter.DeviceID = deviceID
|
||||
|
||||
page, err := s.store.LoginEvents(r.Context(), filter)
|
||||
if err != nil {
|
||||
s.log.Error("device login history failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not read the device history")
|
||||
return
|
||||
}
|
||||
response := adminDeviceDetailResponse{
|
||||
DeviceID: deviceID, Events: page.Events, Total: page.Total,
|
||||
Zone: s.zoneName(), Days: []store.LoginDay{},
|
||||
Addresses: []store.LoginAddress{}, Versions: []store.DeviceVersion{},
|
||||
}
|
||||
// The summary is over the *unfiltered* history for this device, so the headline
|
||||
// counts describe the television rather than whatever window is being looked at.
|
||||
if summaries, err := s.store.LoginDeviceSummaries(
|
||||
r.Context(), store.LoginFilter{DeviceID: deviceID}, s.zoneName(),
|
||||
); err == nil && len(summaries) > 0 {
|
||||
response.Summary = &summaries[0]
|
||||
}
|
||||
if days, err := s.store.LoginDays(r.Context(), filter, s.zoneName()); err == nil {
|
||||
response.Days = days
|
||||
}
|
||||
if addresses, err := s.store.LoginAddresses(r.Context(), filter, 25); err == nil {
|
||||
response.Addresses = addresses
|
||||
}
|
||||
if history, err := s.store.DeviceVersions(r.Context(), []string{deviceID}); err == nil {
|
||||
if versions := history[deviceID]; versions != nil {
|
||||
response.Versions = versions
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// queryInt64s reads a repeated or comma-separated integer parameter, which is how the
|
||||
// notification feed is told which events to mark read.
|
||||
func queryInt64s(r *http.Request, name string) []int64 {
|
||||
values := []int64{}
|
||||
for _, raw := range r.URL.Query()[name] {
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
if parsed, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64); err == nil {
|
||||
values = append(values, parsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
Reference in New Issue
Block a user