package store import ( "context" "fmt" "strings" "time" ) // LoginRetention is how long a sign-in attempt is kept. Long enough to answer "has this // television been connecting all month", short enough that the table stays a working // record rather than an archive nobody prunes. The scheduled housekeeping task reads this // rather than carrying a number of its own, so the retention lives in one place. const LoginRetention = 90 * 24 * time.Hour // Authentication methods. A sign-in is recorded with the route that made it, because // "this television signed in" and "somebody opened the admin console" are different // events wearing the same shape, and an operator reading the list must be able to tell // them apart without inferring it from the device name. const ( LoginMethodPassword = "password" // a television exchanging Emby credentials LoginMethodPIN = "pin" // a television recovering a stored device session LoginMethodAdmin = "admin" // an operator signing into the admin console LoginMethodInstaller = "installer" // the web installer's own password check ) // LoginEvent is one attempt, successful or not. // // It is deliberately a *history* rather than a counter on the session row: a session // carries only the state of the television right now, is overwritten by the next sign-in // and is deleted when the device is removed — so on its own it can never answer "how many // times did this set connect today, at what times, and from which addresses". Every field // here is what was true at the moment of the attempt, including the ones that later move. type LoginEvent struct { ID int64 `json:"id"` OccurredAt time.Time `json:"occurredAt"` EmbyUserID string `json:"embyUserId"` Username string `json:"username"` DeviceID string `json:"deviceId"` DeviceName string `json:"deviceName"` ClientVersion string `json:"clientVersion"` ClientProtocol string `json:"clientProtocol"` IPAddress string `json:"ipAddress"` Success bool `json:"success"` Method string `json:"method"` FailureReason string `json:"failureReason,omitempty"` NewDevice bool `json:"newDevice"` } // LoginFilter narrows the history. Every field is optional and an unset field is "any", // so the console's filter bar maps onto it one control per field with no special cases. type LoginFilter struct { EmbyUserID string DeviceID string IPAddress string Query string // free text over username, device name and address From time.Time To time.Time Outcome string // "success" | "failure" | "" for both Method string Limit int Offset int } // LoginPage is a window onto the history plus the size of the whole match, so the console // can page without asking twice and can say "showing 50 of 1,284". type LoginPage struct { Events []LoginEvent `json:"events"` Total int `json:"total"` Limit int `json:"limit"` Offset int `json:"offset"` } // LoginDeviceSummary is one television's whole relationship with the gateway: how often // it has signed in, when it last managed to, and how many distinct addresses it has come // from. This is the row the devices table is built out of. type LoginDeviceSummary struct { DeviceID string `json:"deviceId"` DeviceName string `json:"deviceName"` EmbyUserID string `json:"embyUserId"` Username string `json:"username"` ClientVersion string `json:"clientVersion"` Logins int `json:"logins"` Failures int `json:"failures"` FirstLogin time.Time `json:"firstLogin"` LastLogin time.Time `json:"lastLogin"` LastIP string `json:"lastIp"` DistinctIPs int `json:"distinctIps"` LoginsToday int `json:"loginsToday"` } // LoginDay is one local day's tally. The day is a string rather than a time because it is // a *label* — the calendar day in the household's zone — and turning it back into an // instant on the way through the console would invite it being re-formatted in the // browser's zone instead, which is how a set that connected at 11pm moves to tomorrow. type LoginDay struct { Day string `json:"day"` Logins int `json:"logins"` Failures int `json:"failures"` Devices int `json:"devices"` } // LoginAddress is one source address a device or a household has been seen from. type LoginAddress struct { IPAddress string `json:"ipAddress"` Logins int `json:"logins"` Failures int `json:"failures"` FirstSeen time.Time `json:"firstSeen"` LastSeen time.Time `json:"lastSeen"` } // RecordLogin writes one attempt. // // Callers treat a failure here as unimportant: a sign-in that succeeded must not be // undone because the audit row could not be written, and a sign-in that failed is already // being refused. It is the caller that decides that, not this function, which is why the // error is still returned. func (s *Store) RecordLogin(ctx context.Context, event LoginEvent) error { if event.Method == "" { event.Method = LoginMethodPassword } _, err := s.pool.Exec(ctx, ` INSERT INTO login_events ( emby_user_id, username, device_id, device_name, client_version, client_protocol, ip_address, success, method, failure_reason, new_device ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, event.EmbyUserID, event.Username, event.DeviceID, event.DeviceName, event.ClientVersion, event.ClientProtocol, event.IPAddress, event.Success, event.Method, event.FailureReason, event.NewDevice) if err != nil { return fmt.Errorf("store: record login: %w", err) } return nil } // loginWhere builds the shared predicate. Both the page query and its count run against // exactly the same clause — derived once rather than written twice, because a filter that // applied to the rows and not to the total is a table that says "50 of 1,284" over // something else's total. func loginWhere(filter LoginFilter) (string, []any) { clauses := []string{"TRUE"} args := []any{} add := func(clause string, value any) { args = append(args, value) clauses = append(clauses, fmt.Sprintf(clause, len(args))) } if id := strings.TrimSpace(filter.EmbyUserID); id != "" { add("emby_user_id = $%d", id) } if id := strings.TrimSpace(filter.DeviceID); id != "" { add("device_id = $%d", id) } if ip := strings.TrimSpace(filter.IPAddress); ip != "" { add("ip_address = $%d", ip) } if query := strings.TrimSpace(filter.Query); query != "" { add("(username ILIKE '%%' || $%[1]d || '%%'"+ " OR device_name ILIKE '%%' || $%[1]d || '%%'"+ " OR ip_address ILIKE '%%' || $%[1]d || '%%')", query) } if !filter.From.IsZero() { add("occurred_at >= $%d", filter.From) } if !filter.To.IsZero() { add("occurred_at < $%d", filter.To) } switch filter.Outcome { case "success": clauses = append(clauses, "success") case "failure": clauses = append(clauses, "NOT success") } if method := strings.TrimSpace(filter.Method); method != "" { add("method = $%d", method) } return strings.Join(clauses, " AND "), args } // LoginEvents answers the filtered history, newest first. func (s *Store) LoginEvents(ctx context.Context, filter LoginFilter) (LoginPage, error) { limit := filter.Limit if limit <= 0 || limit > 500 { limit = 100 } offset := filter.Offset if offset < 0 { offset = 0 } where, args := loginWhere(filter) page := LoginPage{Events: []LoginEvent{}, Limit: limit, Offset: offset} if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM login_events WHERE `+where, args..., ).Scan(&page.Total); err != nil { return page, fmt.Errorf("store: count logins: %w", err) } rows, err := s.pool.Query(ctx, ` SELECT id, occurred_at, emby_user_id, username, device_id, device_name, client_version, client_protocol, ip_address, success, method, failure_reason, new_device FROM login_events WHERE `+where+` ORDER BY occurred_at DESC, id DESC LIMIT $`+fmt.Sprint(len(args)+1)+` OFFSET $`+fmt.Sprint(len(args)+2), append(args, limit, offset)...) if err != nil { return page, fmt.Errorf("store: list logins: %w", err) } defer rows.Close() for rows.Next() { var event LoginEvent if err := rows.Scan( &event.ID, &event.OccurredAt, &event.EmbyUserID, &event.Username, &event.DeviceID, &event.DeviceName, &event.ClientVersion, &event.ClientProtocol, &event.IPAddress, &event.Success, &event.Method, &event.FailureReason, &event.NewDevice, ); err != nil { return page, fmt.Errorf("store: scan login: %w", err) } page.Events = append(page.Events, event) } return page, rows.Err() } // LoginDeviceSummaries is the devices table: one row per television the history knows // about, ordered by how recently it managed to sign in. // // It is grouped from the history rather than joined onto sessions on purpose. A set that // has been removed, or whose session has expired, still connected — and the question this // page exists to answer is about what happened, not about what is currently valid. The // session row is what supplies the *current* name and build where one still exists, which // is why the two are combined here rather than one being preferred outright. func (s *Store) LoginDeviceSummaries(ctx context.Context, filter LoginFilter, zone string) ([]LoginDeviceSummary, error) { where, args := loginWhere(filter) args = append(args, zone) zoneArg := fmt.Sprintf("$%d", len(args)) rows, err := s.pool.Query(ctx, ` WITH history AS ( SELECT * FROM login_events WHERE `+where+` ), summary AS ( SELECT device_id, count(*) FILTER (WHERE success) AS logins, count(*) FILTER (WHERE NOT success) AS failures, min(occurred_at) FILTER (WHERE success) AS first_login, max(occurred_at) FILTER (WHERE success) AS last_login, count(DISTINCT ip_address) FILTER (WHERE ip_address <> '') AS distinct_ips, count(*) FILTER ( WHERE success AND (occurred_at AT TIME ZONE `+zoneArg+`)::date = (now() AT TIME ZONE `+zoneArg+`)::date ) AS logins_today FROM history GROUP BY device_id ), latest AS ( SELECT DISTINCT ON (device_id) device_id, emby_user_id, username, device_name, client_version, ip_address FROM history ORDER BY device_id, occurred_at DESC, id DESC ) SELECT l.device_id, COALESCE(s.device_name, l.device_name), l.emby_user_id, l.username, COALESCE(NULLIF(s.client_version, ''), l.client_version), summary.logins, summary.failures, summary.first_login, summary.last_login, l.ip_address, summary.distinct_ips, summary.logins_today FROM latest l JOIN summary ON summary.device_id = l.device_id LEFT JOIN sessions s ON s.device_id = l.device_id AND s.emby_user_id = l.emby_user_id ORDER BY summary.last_login DESC NULLS LAST, l.device_id`, args...) if err != nil { return nil, fmt.Errorf("store: summarise login devices: %w", err) } defer rows.Close() summaries := []LoginDeviceSummary{} for rows.Next() { var row LoginDeviceSummary var first, last *time.Time if err := rows.Scan( &row.DeviceID, &row.DeviceName, &row.EmbyUserID, &row.Username, &row.ClientVersion, &row.Logins, &row.Failures, &first, &last, &row.LastIP, &row.DistinctIPs, &row.LoginsToday, ); err != nil { return nil, fmt.Errorf("store: scan login device: %w", err) } // A device with failures and no successes has no first or last login, which is a // real answer and not a zero one — the console prints an em dash for it. if first != nil { row.FirstLogin = *first } if last != nil { row.LastLogin = *last } summaries = append(summaries, row) } return summaries, rows.Err() } // LoginDays groups the filtered history by local calendar day, oldest first so the // console can draw it left to right without reversing it. // // The zone is the household's, passed in rather than read here: grouping in UTC would // move every evening sign-in in New Zealand onto the following day, which is exactly the // kind of quiet wrongness an operator would never think to question. func (s *Store) LoginDays(ctx context.Context, filter LoginFilter, zone string) ([]LoginDay, error) { where, args := loginWhere(filter) args = append(args, zone) zoneArg := fmt.Sprintf("$%d", len(args)) rows, err := s.pool.Query(ctx, ` SELECT to_char((occurred_at AT TIME ZONE `+zoneArg+`)::date, 'YYYY-MM-DD') AS day, count(*) FILTER (WHERE success) AS logins, count(*) FILTER (WHERE NOT success) AS failures, count(DISTINCT device_id) FILTER (WHERE success) AS devices FROM login_events WHERE `+where+` GROUP BY day ORDER BY day`, args...) if err != nil { return nil, fmt.Errorf("store: group logins by day: %w", err) } defer rows.Close() days := []LoginDay{} for rows.Next() { var day LoginDay if err := rows.Scan(&day.Day, &day.Logins, &day.Failures, &day.Devices); err != nil { return nil, fmt.Errorf("store: scan login day: %w", err) } days = append(days, day) } return days, rows.Err() } // LoginAddresses is where the filtered attempts came from, busiest first. func (s *Store) LoginAddresses(ctx context.Context, filter LoginFilter, limit int) ([]LoginAddress, error) { if limit <= 0 || limit > 200 { limit = 50 } where, args := loginWhere(filter) args = append(args, limit) rows, err := s.pool.Query(ctx, ` SELECT ip_address, count(*) FILTER (WHERE success) AS logins, count(*) FILTER (WHERE NOT success) AS failures, min(occurred_at), max(occurred_at) FROM login_events WHERE `+where+` AND ip_address <> '' GROUP BY ip_address ORDER BY count(*) DESC, max(occurred_at) DESC LIMIT $`+fmt.Sprint(len(args)), args...) if err != nil { return nil, fmt.Errorf("store: group logins by address: %w", err) } defer rows.Close() addresses := []LoginAddress{} for rows.Next() { var address LoginAddress if err := rows.Scan(&address.IPAddress, &address.Logins, &address.Failures, &address.FirstSeen, &address.LastSeen); err != nil { return nil, fmt.Errorf("store: scan login address: %w", err) } addresses = append(addresses, address) } return addresses, rows.Err() } // LoginTotals is the headline: how the whole filtered window breaks down. It is its own // query rather than a sum of the page above it, for the reason SearchTotals is — the page // is a window, and adding it up would report one screenful's total as the household's. type LoginTotals struct { Logins int `json:"logins"` Failures int `json:"failures"` Devices int `json:"devices"` Users int `json:"users"` Addresses int `json:"addresses"` First time.Time `json:"first"` Last time.Time `json:"last"` } func (s *Store) LoginTotals(ctx context.Context, filter LoginFilter) (LoginTotals, error) { where, args := loginWhere(filter) var totals LoginTotals var first, last *time.Time err := s.pool.QueryRow(ctx, ` SELECT count(*) FILTER (WHERE success), count(*) FILTER (WHERE NOT success), count(DISTINCT device_id) FILTER (WHERE device_id <> ''), count(DISTINCT emby_user_id) FILTER (WHERE emby_user_id <> ''), count(DISTINCT ip_address) FILTER (WHERE ip_address <> ''), min(occurred_at), max(occurred_at) FROM login_events WHERE `+where, args...). Scan(&totals.Logins, &totals.Failures, &totals.Devices, &totals.Users, &totals.Addresses, &first, &last) if err != nil { return totals, fmt.Errorf("store: login totals: %w", err) } if first != nil { totals.First = *first } if last != nil { totals.Last = *last } return totals, nil } // DeviceHasLoggedIn reports whether this television has ever successfully signed in // before. It is what makes "new device registered" a distinguishable event rather than // one indistinguishable from every subsequent sign-in by the same set — asked before the // attempt is recorded, so the current sign-in cannot answer for itself. func (s *Store) DeviceHasLoggedIn(ctx context.Context, embyUserID, deviceID string) (bool, error) { if deviceID == "" { return true, nil } var exists bool err := s.pool.QueryRow(ctx, ` SELECT EXISTS ( SELECT 1 FROM login_events WHERE device_id = $1 AND emby_user_id = $2 AND success )`, deviceID, embyUserID).Scan(&exists) if err != nil { return false, fmt.Errorf("store: device login lookup: %w", err) } return exists, nil } // PruneLoginEvents drops attempts older than the retention period. Called by the // scheduled housekeeping task rather than on the write path: pruning inside a sign-in // would put a delete over the whole table in front of somebody waiting to watch // something. func (s *Store) PruneLoginEvents(ctx context.Context, retention time.Duration) (int64, error) { if retention <= 0 { retention = LoginRetention } tag, err := s.pool.Exec(ctx, `DELETE FROM login_events WHERE occurred_at < now() - $1::interval`, fmt.Sprintf("%d seconds", int64(retention.Seconds()))) if err != nil { return 0, fmt.Errorf("store: prune login events: %w", err) } return tag.RowsAffected(), nil }