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 == "" { origin := s.resolveClientIP(r) event.IPAddress = origin.String() // One line per sign-in attempt, so an operator can see which header the address // came from when a reverse proxy is in front of the gateway. Debug keeps it out // of the ordinary log while still being there when the question is asked. s.log.Debug("resolved login client ip", "ip", origin.String(), "via", origin.Via, "device_id", event.DeviceID) } 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.householdLocation() 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 { return s.householdTimezoneName() } 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 }