0.1.38 gateway
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user