2026-08-14 09:40:03 +12:00
|
|
|
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
|
2026-08-25 11:39:55 +12:00
|
|
|
LoginMethodPIN = "pin" // a television recovering a stored device session
|
2026-08-14 09:40:03 +12:00
|
|
|
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
|
|
|
|
|
}
|