0.1.38 gateway

This commit is contained in:
ponzischeme89
2026-08-14 09:40:03 +12:00
parent abc392d30b
commit 5e2ed3d12e
2847 changed files with 1072928 additions and 3783 deletions
+447
View File
@@ -0,0 +1,447 @@
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
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
}