Files
memby/server/internal/api/logins.go
T

259 lines
9.9 KiB
Go
Raw Normal View History

2026-08-14 09:40:03 +12:00
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
}
2026-08-17 19:09:17 +12:00
location := s.householdLocation()
2026-08-14 09:40:03 +12:00
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 {
2026-08-17 19:09:17 +12:00
return s.householdTimezoneName()
2026-08-14 09:40:03 +12:00
}
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
}