0.1.38 gateway
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AdminEventRetention is how long an administrative event is kept. The feed is a rolling
|
||||
// operational record, not an archive: anything worth keeping longer is already a log line
|
||||
// or a row in the table the event was about.
|
||||
const AdminEventRetention = 30 * 24 * time.Hour
|
||||
|
||||
// Severities. Three, not six — this decides whether a row is worth an operator's
|
||||
// attention, and a scale finer than that is one nobody applies consistently.
|
||||
const (
|
||||
SeverityInfo = "info"
|
||||
SeverityWarning = "warning"
|
||||
SeverityError = "error"
|
||||
)
|
||||
|
||||
// AdminEvent is one thing that happened, in the shape the console and every integration
|
||||
// read it in.
|
||||
//
|
||||
// The vocabulary is deliberately about *shape* rather than about any particular subject:
|
||||
// Type is a routing key, Title is the headline, Summary is the sentence, Actor is who or
|
||||
// what caused it and Target is what it happened to. A publisher that cannot fill one
|
||||
// leaves it blank. Metadata is for the fields only that publisher's readers understand,
|
||||
// and nothing in the console depends on any key in it, so adding one is never a
|
||||
// migration.
|
||||
//
|
||||
// Link is a console path (not a URL), so an event can carry the operator to the page that
|
||||
// explains it. It is a path because the console does not reliably know its own external
|
||||
// address, exactly as the gateway does not for a subtitle it serves.
|
||||
type AdminEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
Type string `json:"type"`
|
||||
Severity string `json:"severity"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Actor string `json:"actor,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Link string `json:"link,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
ReadAt *time.Time `json:"readAt,omitempty"`
|
||||
}
|
||||
|
||||
// Read reports whether the operator has already seen this one.
|
||||
func (e AdminEvent) Read() bool { return e.ReadAt != nil }
|
||||
|
||||
// AdminEventFilter narrows the feed. Types is a set rather than a single value because
|
||||
// the natural question is "show me the authentication ones", which is several types.
|
||||
type AdminEventFilter struct {
|
||||
Types []string
|
||||
Severities []string
|
||||
UnreadOnly bool
|
||||
Since time.Time
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// AdminEventPage carries the unread count alongside the rows, because the bell needs it
|
||||
// and asking for it separately would mean the badge and the list could disagree.
|
||||
type AdminEventPage struct {
|
||||
Events []AdminEvent `json:"events"`
|
||||
Total int `json:"total"`
|
||||
Unread int `json:"unread"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
// RecordAdminEvent persists one event and fills in its assigned id and timestamp, which
|
||||
// the caller needs in order to broadcast the same row it stored — a subscriber shown an
|
||||
// event with no id could neither mark it read nor recognise it on a later poll.
|
||||
func (s *Store) RecordAdminEvent(ctx context.Context, event AdminEvent) (AdminEvent, error) {
|
||||
if event.Severity == "" {
|
||||
event.Severity = SeverityInfo
|
||||
}
|
||||
if len(event.Metadata) == 0 {
|
||||
event.Metadata = json.RawMessage(`{}`)
|
||||
}
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO admin_events (type, severity, title, summary, actor, target, link, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, occurred_at`,
|
||||
event.Type, event.Severity, event.Title, event.Summary,
|
||||
event.Actor, event.Target, event.Link, []byte(event.Metadata),
|
||||
).Scan(&event.ID, &event.OccurredAt)
|
||||
if err != nil {
|
||||
return event, fmt.Errorf("store: record admin event: %w", err)
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func adminEventWhere(filter AdminEventFilter) (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 len(filter.Types) > 0 {
|
||||
add("type = ANY($%d::text[])", filter.Types)
|
||||
}
|
||||
if len(filter.Severities) > 0 {
|
||||
add("severity = ANY($%d::text[])", filter.Severities)
|
||||
}
|
||||
if filter.UnreadOnly {
|
||||
clauses = append(clauses, "read_at IS NULL")
|
||||
}
|
||||
if !filter.Since.IsZero() {
|
||||
add("occurred_at >= $%d", filter.Since)
|
||||
}
|
||||
return strings.Join(clauses, " AND "), args
|
||||
}
|
||||
|
||||
// AdminEvents answers the filtered feed, newest first.
|
||||
func (s *Store) AdminEvents(ctx context.Context, filter AdminEventFilter) (AdminEventPage, error) {
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
offset := filter.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
where, args := adminEventWhere(filter)
|
||||
|
||||
page := AdminEventPage{Events: []AdminEvent{}, Limit: limit, Offset: offset}
|
||||
// The unread count is over the *whole* feed rather than the filter: the badge says how
|
||||
// much news there is, and a filtered view that also shrank the badge would report the
|
||||
// household as caught up because the operator had narrowed the list.
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT count(*) FILTER (WHERE `+where+`), count(*) FILTER (WHERE read_at IS NULL)
|
||||
FROM admin_events`, args...,
|
||||
).Scan(&page.Total, &page.Unread); err != nil {
|
||||
return page, fmt.Errorf("store: count admin events: %w", err)
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, occurred_at, type, severity, title, summary, actor, target, link,
|
||||
metadata, read_at
|
||||
FROM admin_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 admin events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var event AdminEvent
|
||||
var metadata []byte
|
||||
if err := rows.Scan(&event.ID, &event.OccurredAt, &event.Type, &event.Severity,
|
||||
&event.Title, &event.Summary, &event.Actor, &event.Target, &event.Link,
|
||||
&metadata, &event.ReadAt); err != nil {
|
||||
return page, fmt.Errorf("store: scan admin event: %w", err)
|
||||
}
|
||||
event.Metadata = json.RawMessage(metadata)
|
||||
page.Events = append(page.Events, event)
|
||||
}
|
||||
return page, rows.Err()
|
||||
}
|
||||
|
||||
// MarkAdminEventsRead marks the given ids, or every unread event when none are given.
|
||||
func (s *Store) MarkAdminEventsRead(ctx context.Context, ids []int64) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`UPDATE admin_events SET read_at = now() WHERE read_at IS NULL`)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: mark admin events read: %w", err)
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`UPDATE admin_events SET read_at = now() WHERE read_at IS NULL AND id = ANY($1::bigint[])`,
|
||||
ids)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: mark admin events read: %w", err)
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// UnreadAdminEvents is the badge on its own, for callers that want the count without the
|
||||
// rows behind it.
|
||||
func (s *Store) UnreadAdminEvents(ctx context.Context) (int, error) {
|
||||
var unread int
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM admin_events WHERE read_at IS NULL`).Scan(&unread)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: count unread admin events: %w", err)
|
||||
}
|
||||
return unread, nil
|
||||
}
|
||||
|
||||
// AdminEventTypeCount is one type's share of the feed, for the filter control — a list of
|
||||
// types built from what has actually been published cannot offer a filter that matches
|
||||
// nothing, and cannot miss a type added after this was written.
|
||||
type AdminEventTypeCount struct {
|
||||
Type string `json:"type"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func (s *Store) AdminEventTypes(ctx context.Context, since time.Time) ([]AdminEventTypeCount, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT type, count(*) FROM admin_events
|
||||
WHERE ($1::timestamptz IS NULL OR occurred_at >= $1)
|
||||
GROUP BY type ORDER BY count(*) DESC, type`, nullableTime(since))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: admin event types: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
counts := []AdminEventTypeCount{}
|
||||
for rows.Next() {
|
||||
var count AdminEventTypeCount
|
||||
if err := rows.Scan(&count.Type, &count.Count); err != nil {
|
||||
return nil, fmt.Errorf("store: scan admin event type: %w", err)
|
||||
}
|
||||
counts = append(counts, count)
|
||||
}
|
||||
return counts, rows.Err()
|
||||
}
|
||||
|
||||
// PruneAdminEvents drops events past the retention period.
|
||||
func (s *Store) PruneAdminEvents(ctx context.Context, retention time.Duration) (int64, error) {
|
||||
if retention <= 0 {
|
||||
retention = AdminEventRetention
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`DELETE FROM admin_events WHERE occurred_at < now() - $1::interval`,
|
||||
fmt.Sprintf("%d seconds", int64(retention.Seconds())))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: prune admin events: %w", err)
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func nullableTime(value time.Time) any {
|
||||
if value.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -119,6 +119,101 @@ type JourneyActionStat struct {
|
||||
Journeys int64 `json:"journeys"`
|
||||
}
|
||||
|
||||
// ViewStats is app use measured at the launcher, rather than media playback. A visit is a
|
||||
// foreground journey that reached home; a viewer is one signed-in profile in that window.
|
||||
type ViewStats struct {
|
||||
Visits int64 `json:"visits"`
|
||||
Viewers int64 `json:"viewers"`
|
||||
}
|
||||
|
||||
type ViewBucket struct {
|
||||
Label string `json:"label"`
|
||||
Visits int64 `json:"visits"`
|
||||
Viewers int64 `json:"viewers"`
|
||||
}
|
||||
|
||||
type ViewsReport struct {
|
||||
Today ViewStats `json:"today"`
|
||||
LastWeek ViewStats `json:"lastWeek"`
|
||||
Daily []ViewBucket `json:"daily"`
|
||||
Hourly []ViewBucket `json:"hourly"`
|
||||
BusiestHour string `json:"busiestHour"`
|
||||
}
|
||||
|
||||
// ViewsReport aggregates only app launcher openings. `journey_start` remains in the
|
||||
// predicate so deployments gain a useful history before clients begin sending home_open.
|
||||
func (s *Store) ViewsReport(ctx context.Context, now time.Time) (ViewsReport, error) {
|
||||
var out ViewsReport
|
||||
location, err := time.LoadLocation("Pacific/Auckland")
|
||||
if err != nil {
|
||||
location = time.UTC
|
||||
}
|
||||
localNow := now.In(location)
|
||||
todayStart := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, location).UTC()
|
||||
// The dashboard comparison ends at the same elapsed point in last week's day, not at
|
||||
// midnight, so a morning view is compared with another morning rather than a full day.
|
||||
lastWeekStart := todayStart.AddDate(0, 0, -7)
|
||||
lastWeekEnd := lastWeekStart.Add(now.Sub(todayStart))
|
||||
count := func(start, end time.Time) (ViewStats, error) {
|
||||
var value ViewStats
|
||||
err := s.pool.QueryRow(ctx, `SELECT count(DISTINCT journey_id), count(DISTINCT emby_user_id)
|
||||
FROM journey_events WHERE occurred_at >= $1 AND occurred_at < $2
|
||||
AND action IN ('home_open', 'journey_start')`, start, end).Scan(&value.Visits, &value.Viewers)
|
||||
return value, err
|
||||
}
|
||||
if out.Today, err = count(todayStart, now); err != nil {
|
||||
return out, fmt.Errorf("store: today views: %w", err)
|
||||
}
|
||||
if out.LastWeek, err = count(lastWeekStart, lastWeekEnd); err != nil {
|
||||
return out, fmt.Errorf("store: last-week views: %w", err)
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `SELECT to_char(timezone('Pacific/Auckland', occurred_at), 'YYYY-MM-DD'),
|
||||
count(DISTINCT journey_id), count(DISTINCT emby_user_id)
|
||||
FROM journey_events WHERE occurred_at >= $1 AND action IN ('home_open', 'journey_start')
|
||||
GROUP BY 1 ORDER BY 1`, todayStart.AddDate(0, 0, -29))
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("store: daily views: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var bucket ViewBucket
|
||||
if err := rows.Scan(&bucket.Label, &bucket.Visits, &bucket.Viewers); err != nil {
|
||||
rows.Close()
|
||||
return out, err
|
||||
}
|
||||
out.Daily = append(out.Daily, bucket)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return out, err
|
||||
}
|
||||
rows.Close()
|
||||
rows, err = s.pool.Query(ctx, `SELECT to_char(timezone('Pacific/Auckland', occurred_at), 'HH24:00'),
|
||||
count(DISTINCT journey_id), count(DISTINCT emby_user_id)
|
||||
FROM journey_events WHERE occurred_at >= $1 AND occurred_at < $2 AND action IN ('home_open', 'journey_start')
|
||||
GROUP BY 1 ORDER BY 1`, todayStart, now)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("store: hourly views: %w", err)
|
||||
}
|
||||
var busiestVisits int64
|
||||
for rows.Next() {
|
||||
var bucket ViewBucket
|
||||
if err := rows.Scan(&bucket.Label, &bucket.Visits, &bucket.Viewers); err != nil {
|
||||
rows.Close()
|
||||
return out, err
|
||||
}
|
||||
out.Hourly = append(out.Hourly, bucket)
|
||||
if bucket.Visits > busiestVisits {
|
||||
busiestVisits = bucket.Visits
|
||||
out.BusiestHour = bucket.Label
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Event kinds. Impressions say a row was drawn; focus says the remote actually landed
|
||||
// on it and for how long; select says something was opened from it.
|
||||
const (
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// IntegrationsKey is the app_settings row holding every configured external destination.
|
||||
//
|
||||
// One document rather than a table, for the reason the feature policy is one document:
|
||||
// what an integration *is* belongs to the code, and only the operator's choices belong in
|
||||
// the database. A schema per integration would make adding a second one a migration.
|
||||
const IntegrationsKey = "integrations"
|
||||
|
||||
// MaxIntegrations bounds the list. This is a household's operations console, not a
|
||||
// notification fan-out service, and an unbounded list is an unbounded number of outbound
|
||||
// requests per event.
|
||||
const MaxIntegrations = 20
|
||||
|
||||
// IntegrationDiscord is the only kind today. The kind is stored rather than inferred from
|
||||
// the URL, because a second webhook-shaped destination — Slack, ntfy — would be
|
||||
// indistinguishable otherwise and would silently receive Discord's payload.
|
||||
const IntegrationDiscord = "discord"
|
||||
|
||||
// Integration is one configured destination.
|
||||
//
|
||||
// Events is an explicit selection rather than a "send everything" flag with exceptions:
|
||||
// an integration that quietly gained a new event type because one was added to the
|
||||
// gateway is one that starts posting things nobody asked for. An empty selection
|
||||
// therefore sends nothing, and the console says so.
|
||||
type Integration struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
URL string `json:"url"`
|
||||
Events []string `json:"events"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Redacted is the integration as the console may read it.
|
||||
//
|
||||
// The webhook URL *is* the credential — anybody holding it can post into the channel — so
|
||||
// it is never returned, exactly as the MDBList key and the OpenSubtitles login are never
|
||||
// returned. What the console needs is whether one is set and enough of it to recognise
|
||||
// which channel this row is, which is what Hint carries.
|
||||
type RedactedIntegration struct {
|
||||
Integration
|
||||
URL string `json:"url,omitempty"` // always empty; the field stays for shape
|
||||
HasURL bool `json:"hasUrl"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
type IntegrationSettings struct {
|
||||
Integrations []Integration `json:"integrations"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// webhookHint is the recognisable, non-secret part of a Discord webhook URL: the channel
|
||||
// id in the path, and never the token after it. A hint that included any of the token
|
||||
// would be a leak wearing a shorter name.
|
||||
func webhookHint(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(strings.TrimSuffix(raw, "/"), "/")
|
||||
// .../api/webhooks/<channel id>/<token>
|
||||
if len(parts) >= 2 {
|
||||
if id := parts[len(parts)-2]; id != "" && len(id) <= 32 {
|
||||
return "…/" + id
|
||||
}
|
||||
}
|
||||
return "configured"
|
||||
}
|
||||
|
||||
// Redact prepares an integration for the console.
|
||||
func (i Integration) Redact() RedactedIntegration {
|
||||
return RedactedIntegration{
|
||||
Integration: Integration{
|
||||
ID: i.ID, Kind: i.Kind, Name: i.Name, Enabled: i.Enabled,
|
||||
Events: i.Events, CreatedAt: i.CreatedAt, UpdatedAt: i.UpdatedAt,
|
||||
},
|
||||
HasURL: strings.TrimSpace(i.URL) != "",
|
||||
Hint: webhookHint(i.URL),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeIntegrations(settings IntegrationSettings) IntegrationSettings {
|
||||
seen := map[string]bool{}
|
||||
cleaned := make([]Integration, 0, len(settings.Integrations))
|
||||
for _, integration := range settings.Integrations {
|
||||
integration.ID = strings.TrimSpace(integration.ID)
|
||||
if integration.ID == "" || seen[integration.ID] || len(cleaned) >= MaxIntegrations {
|
||||
continue
|
||||
}
|
||||
seen[integration.ID] = true
|
||||
if integration.Kind == "" {
|
||||
integration.Kind = IntegrationDiscord
|
||||
}
|
||||
integration.Name = strings.TrimSpace(integration.Name)
|
||||
if integration.Name == "" {
|
||||
integration.Name = "Discord"
|
||||
}
|
||||
if runes := []rune(integration.Name); len(runes) > 60 {
|
||||
integration.Name = string(runes[:60])
|
||||
}
|
||||
integration.URL = strings.TrimSpace(integration.URL)
|
||||
// An integration with no destination cannot be on. Storing it as enabled would
|
||||
// give the console a switch that reports "on" over something that can never
|
||||
// deliver, which is the state the subtitle settings deliberately refuse too.
|
||||
if integration.URL == "" {
|
||||
integration.Enabled = false
|
||||
}
|
||||
events := make([]string, 0, len(integration.Events))
|
||||
eventSeen := map[string]bool{}
|
||||
for _, event := range integration.Events {
|
||||
event = strings.TrimSpace(event)
|
||||
if event == "" || eventSeen[event] || len(events) >= 40 {
|
||||
continue
|
||||
}
|
||||
eventSeen[event] = true
|
||||
events = append(events, event)
|
||||
}
|
||||
integration.Events = events
|
||||
cleaned = append(cleaned, integration)
|
||||
}
|
||||
settings.Integrations = cleaned
|
||||
return settings
|
||||
}
|
||||
|
||||
func (s *Store) Integrations(ctx context.Context) (IntegrationSettings, error) {
|
||||
empty := IntegrationSettings{Integrations: []Integration{}}
|
||||
var raw []byte
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT value FROM app_settings WHERE key = $1`, IntegrationsKey).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return empty, nil
|
||||
}
|
||||
if err != nil {
|
||||
return empty, fmt.Errorf("store: read integrations: %w", err)
|
||||
}
|
||||
var settings IntegrationSettings
|
||||
if err := json.Unmarshal(raw, &settings); err != nil {
|
||||
return empty, fmt.Errorf("store: decode integrations: %w", err)
|
||||
}
|
||||
settings = normalizeIntegrations(settings)
|
||||
if settings.Integrations == nil {
|
||||
settings.Integrations = []Integration{}
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetIntegrations(ctx context.Context, settings IntegrationSettings) error {
|
||||
settings = normalizeIntegrations(settings)
|
||||
settings.UpdatedAt = time.Now().UTC()
|
||||
raw, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
INSERT INTO app_settings (key, value, updated_at)
|
||||
VALUES ($1, $2::jsonb, now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
|
||||
IntegrationsKey, string(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: write integrations: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const sampleWebhook = "https://discord.com/api/webhooks/1234567890/abcdefSECRETtoken"
|
||||
|
||||
// The redaction rule is the one thing here that must not be got wrong. The webhook URL is
|
||||
// the credential: anybody holding it can post into the household's channel, and this
|
||||
// console is read over the open internet.
|
||||
|
||||
func TestRedactNeverReturnsTheWebhookAddress(t *testing.T) {
|
||||
redacted := Integration{ID: "a", Name: "Family", URL: sampleWebhook}.Redact()
|
||||
if redacted.URL != "" {
|
||||
t.Fatalf("the webhook address must never leave the server, got %q", redacted.URL)
|
||||
}
|
||||
if strings.Contains(redacted.Hint, "SECRET") {
|
||||
t.Fatalf("the hint leaked part of the token: %q", redacted.Hint)
|
||||
}
|
||||
if !redacted.HasURL {
|
||||
t.Fatal("the console still has to know one is configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactHintNamesTheChannelSoRowsAreTellableApart(t *testing.T) {
|
||||
redacted := Integration{ID: "a", URL: sampleWebhook}.Redact()
|
||||
if !strings.Contains(redacted.Hint, "1234567890") {
|
||||
t.Fatalf("the hint should carry the channel id, got %q", redacted.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactSaysNothingWhenNothingIsConfigured(t *testing.T) {
|
||||
redacted := Integration{ID: "a"}.Redact()
|
||||
if redacted.HasURL || redacted.Hint != "" {
|
||||
t.Fatalf("an unconfigured integration should claim nothing, got %+v", redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRefusesToEnableAnIntegrationWithNoDestination(t *testing.T) {
|
||||
// A switch reading "on" over something that can never deliver is the state the
|
||||
// subtitle settings deliberately refuse too.
|
||||
settings := normalizeIntegrations(IntegrationSettings{Integrations: []Integration{
|
||||
{ID: "a", Enabled: true, URL: " "},
|
||||
}})
|
||||
if settings.Integrations[0].Enabled {
|
||||
t.Fatal("an integration with no address must not be stored as enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDropsDuplicateAndAnonymousRows(t *testing.T) {
|
||||
settings := normalizeIntegrations(IntegrationSettings{Integrations: []Integration{
|
||||
{ID: "a", URL: sampleWebhook},
|
||||
{ID: "a", URL: sampleWebhook},
|
||||
{ID: " ", URL: sampleWebhook},
|
||||
}})
|
||||
if len(settings.Integrations) != 1 {
|
||||
t.Fatalf("expected one surviving row, got %d", len(settings.Integrations))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDeduplicatesTheEventSelection(t *testing.T) {
|
||||
// A repeated event would deliver the same message twice to one channel.
|
||||
settings := normalizeIntegrations(IntegrationSettings{Integrations: []Integration{
|
||||
{ID: "a", URL: sampleWebhook, Events: []string{"auth.login", "auth.login", " ", "device.registered"}},
|
||||
}})
|
||||
events := settings.Integrations[0].Events
|
||||
if len(events) != 2 || events[0] != "auth.login" || events[1] != "device.registered" {
|
||||
t.Fatalf("unexpected selection %v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBoundsTheList(t *testing.T) {
|
||||
// Every event costs one outbound request per enabled destination.
|
||||
many := make([]Integration, MaxIntegrations+5)
|
||||
for i := range many {
|
||||
many[i] = Integration{ID: string(rune('a'+i%26)) + itoa(i), URL: sampleWebhook}
|
||||
}
|
||||
settings := normalizeIntegrations(IntegrationSettings{Integrations: many})
|
||||
if len(settings.Integrations) != MaxIntegrations {
|
||||
t.Fatalf("expected the list capped at %d, got %d", MaxIntegrations, len(settings.Integrations))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDefaultsTheKindSoAnOlderDocumentStillDelivers(t *testing.T) {
|
||||
// The kind was introduced with the second transport in mind; a row written before it
|
||||
// has none, and reading that as "no transport" would silently stop delivering.
|
||||
settings := normalizeIntegrations(IntegrationSettings{Integrations: []Integration{
|
||||
{ID: "a", URL: sampleWebhook},
|
||||
}})
|
||||
if settings.Integrations[0].Kind != IntegrationDiscord {
|
||||
t.Fatalf("expected a default kind, got %q", settings.Integrations[0].Kind)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The filter predicate is the one piece of this feature worth pinning without a database:
|
||||
// it is built by string concatenation with hand-numbered placeholders, and the failure it
|
||||
// can produce is not a compile error or a crash but a *wrong answer* — a page of somebody
|
||||
// else's sign-ins, or a total that does not match the rows above it.
|
||||
|
||||
func TestLoginWhereIsEmptyForAnEmptyFilter(t *testing.T) {
|
||||
where, args := loginWhere(LoginFilter{})
|
||||
if where != "TRUE" {
|
||||
t.Fatalf("an unfiltered history should match everything, got %q", where)
|
||||
}
|
||||
if len(args) != 0 {
|
||||
t.Fatalf("no filter means no arguments, got %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWhereNumbersPlaceholdersInOrder(t *testing.T) {
|
||||
// Every argument must be referenced exactly once, by its own position. A duplicated or
|
||||
// skipped number is how one filter silently applies another's value.
|
||||
where, args := loginWhere(LoginFilter{
|
||||
EmbyUserID: "user-1",
|
||||
DeviceID: "device-1",
|
||||
IPAddress: "10.0.0.9",
|
||||
From: time.Now().Add(-time.Hour),
|
||||
To: time.Now(),
|
||||
Method: LoginMethodPassword,
|
||||
})
|
||||
if len(args) != 6 {
|
||||
t.Fatalf("expected six bound values, got %d (%v)", len(args), args)
|
||||
}
|
||||
for position := 1; position <= len(args); position++ {
|
||||
placeholder := "$" + itoa(position)
|
||||
if strings.Count(where, placeholder) != 1 {
|
||||
t.Fatalf("placeholder %s appears %d times in %q",
|
||||
placeholder, strings.Count(where, placeholder), where)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWhereQueryReusesOneArgumentAcrossThreeColumns(t *testing.T) {
|
||||
// The free-text control searches three columns from a single bound value. If it ever
|
||||
// bound three, every later placeholder would be numbered wrongly.
|
||||
where, args := loginWhere(LoginFilter{Query: "lounge"})
|
||||
if len(args) != 1 {
|
||||
t.Fatalf("free text should bind one value, got %d", len(args))
|
||||
}
|
||||
if strings.Count(where, "$1") != 3 {
|
||||
t.Fatalf("free text should search three columns, got %q", where)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWhereOutcomeBindsNothing(t *testing.T) {
|
||||
// Outcome is a literal predicate rather than a bound value; if it ever consumed an
|
||||
// argument slot without appending to args, the numbering after it would be wrong.
|
||||
for _, outcome := range []string{"success", "failure"} {
|
||||
where, args := loginWhere(LoginFilter{Outcome: outcome, DeviceID: "device-1"})
|
||||
if len(args) != 1 {
|
||||
t.Fatalf("%s: expected one bound value, got %d", outcome, len(args))
|
||||
}
|
||||
if !strings.Contains(where, "$1") {
|
||||
t.Fatalf("%s: the device filter lost its placeholder: %q", outcome, where)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWhereIgnoresBlankAndWhitespaceFilters(t *testing.T) {
|
||||
// The console sends empty controls as empty strings. A blank that reached the query
|
||||
// would match no rows at all rather than every row, which reads as "this television
|
||||
// has never connected".
|
||||
where, args := loginWhere(LoginFilter{EmbyUserID: " ", DeviceID: "", IPAddress: "\t"})
|
||||
if where != "TRUE" || len(args) != 0 {
|
||||
t.Fatalf("blank filters should not narrow anything, got %q %v", where, args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWhereUnknownOutcomeMatchesBoth(t *testing.T) {
|
||||
// An outcome the server does not recognise must mean "any", not "none": the filter is
|
||||
// a convenience, and a typo in a bookmarked URL should not empty the page.
|
||||
where, _ := loginWhere(LoginFilter{Outcome: "maybe"})
|
||||
if strings.Contains(where, "success") {
|
||||
t.Fatalf("an unrecognised outcome should not filter, got %q", where)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(value int) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
}
|
||||
digits := ""
|
||||
for value > 0 {
|
||||
digits = string(rune('0'+value%10)) + digits
|
||||
value /= 10
|
||||
}
|
||||
return digits
|
||||
}
|
||||
@@ -18,6 +18,33 @@ type MediaRequest struct {
|
||||
RequestedAt time.Time `json:"requestedAt"`
|
||||
}
|
||||
|
||||
// RequestUsage is the operator-facing use of the request feature. A recorded request is
|
||||
// one that Memby has already handed to Radarr or Sonarr; their later download state is
|
||||
// deliberately not duplicated here.
|
||||
type RequestUsage struct {
|
||||
UserID string `json:"userId"`
|
||||
Requests int64 `json:"requests"`
|
||||
LastRequest time.Time `json:"lastRequest,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Store) RequestUsage(ctx context.Context) ([]RequestUsage, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT emby_user_id, count(*), max(requested_at)
|
||||
FROM media_requests GROUP BY emby_user_id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: request usage: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []RequestUsage{}
|
||||
for rows.Next() {
|
||||
var value RequestUsage
|
||||
if err := rows.Scan(&value.UserID, &value.Requests, &value.LastRequest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MediaRequestLimit caps what one viewer's page will read back. A household that has been
|
||||
// asking for things for two years should not turn the page into an unbounded query, and
|
||||
// nobody scrolls past the most recent hundred by remote.
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// TaskRunRetention is how much run history a task keeps. Enough to see a pattern in an
|
||||
// overnight job, not so much that a task running every five minutes fills the table.
|
||||
const TaskRunRetention = 30 * 24 * time.Hour
|
||||
|
||||
// Run statuses.
|
||||
const (
|
||||
TaskRunning = "running"
|
||||
TaskSuccess = "success"
|
||||
TaskFailed = "failed"
|
||||
TaskSkipped = "skipped"
|
||||
)
|
||||
|
||||
// Triggers. A run says how it came to happen, because "it has not run since Tuesday" and
|
||||
// "it has only ever run when somebody pressed the button" are different problems.
|
||||
const (
|
||||
TriggerSchedule = "schedule"
|
||||
TriggerManual = "manual"
|
||||
TriggerStartup = "startup"
|
||||
)
|
||||
|
||||
// TaskRun is one execution.
|
||||
type TaskRun struct {
|
||||
ID int64 `json:"id"`
|
||||
TaskID string `json:"taskId"`
|
||||
Trigger string `json:"trigger"`
|
||||
Status string `json:"status"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// TaskSettings is an operator's override for one task. IntervalSeconds of 0 means "the
|
||||
// interval the task declares", so a task whose schedule is changed in code takes effect
|
||||
// for every operator who never overrode it.
|
||||
type TaskSettings struct {
|
||||
TaskID string `json:"taskId"`
|
||||
Enabled bool `json:"enabled"`
|
||||
IntervalSeconds int `json:"intervalSeconds"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// BeginTaskRun opens a run and returns its id. The row exists before the work starts so a
|
||||
// task killed by a restart leaves evidence it began — which is the only way to tell a job
|
||||
// that hangs from one that was never scheduled.
|
||||
func (s *Store) BeginTaskRun(ctx context.Context, taskID, trigger string) (int64, error) {
|
||||
var id int64
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO scheduled_task_runs (task_id, trigger, status)
|
||||
VALUES ($1, $2, $3) RETURNING id`, taskID, trigger, TaskRunning).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: begin task run: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// FinishTaskRun closes a run with its outcome.
|
||||
func (s *Store) FinishTaskRun(ctx context.Context, id int64, status, detail, failure string) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE scheduled_task_runs
|
||||
SET status = $2, finished_at = now(), detail = $3, error = $4,
|
||||
duration_ms = GREATEST(0, EXTRACT(EPOCH FROM (now() - started_at)) * 1000)::bigint
|
||||
WHERE id = $1`, id, status, detail, failure)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: finish task run: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AbandonRunningTasks closes runs left open by a process that went away.
|
||||
//
|
||||
// Called once at start-up: a run in "running" with nothing running it is a lie the
|
||||
// console would otherwise print for ever, and it is indistinguishable from a genuinely
|
||||
// long job unless it is resolved at the one moment the answer is known — the moment the
|
||||
// process that could have owned it has just started.
|
||||
func (s *Store) AbandonRunningTasks(ctx context.Context) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE scheduled_task_runs
|
||||
SET status = $1, finished_at = now(),
|
||||
error = 'interrupted by a server restart',
|
||||
duration_ms = GREATEST(0, EXTRACT(EPOCH FROM (now() - started_at)) * 1000)::bigint
|
||||
WHERE status = $2`, TaskFailed, TaskRunning)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: abandon running tasks: %w", err)
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// LatestTaskRuns is the most recent run per task, in one query — the console draws this
|
||||
// beside every task and a query per task would grow with the registry.
|
||||
func (s *Store) LatestTaskRuns(ctx context.Context) (map[string]TaskRun, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT DISTINCT ON (task_id)
|
||||
id, task_id, trigger, status, started_at, finished_at, duration_ms, detail, error
|
||||
FROM scheduled_task_runs
|
||||
ORDER BY task_id, started_at DESC, id DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: latest task runs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
latest := map[string]TaskRun{}
|
||||
for rows.Next() {
|
||||
var run TaskRun
|
||||
if err := rows.Scan(&run.ID, &run.TaskID, &run.Trigger, &run.Status,
|
||||
&run.StartedAt, &run.FinishedAt, &run.DurationMS, &run.Detail,
|
||||
&run.Error); err != nil {
|
||||
return nil, fmt.Errorf("store: scan task run: %w", err)
|
||||
}
|
||||
latest[run.TaskID] = run
|
||||
}
|
||||
return latest, rows.Err()
|
||||
}
|
||||
|
||||
// TaskRuns is the history for one task, or for every task when taskID is blank.
|
||||
func (s *Store) TaskRuns(ctx context.Context, taskID string, limit int) ([]TaskRun, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, task_id, trigger, status, started_at, finished_at, duration_ms, detail, error
|
||||
FROM scheduled_task_runs
|
||||
WHERE ($1 = '' OR task_id = $1)
|
||||
ORDER BY started_at DESC, id DESC
|
||||
LIMIT $2`, taskID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list task runs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
runs := []TaskRun{}
|
||||
for rows.Next() {
|
||||
var run TaskRun
|
||||
if err := rows.Scan(&run.ID, &run.TaskID, &run.Trigger, &run.Status,
|
||||
&run.StartedAt, &run.FinishedAt, &run.DurationMS, &run.Detail,
|
||||
&run.Error); err != nil {
|
||||
return nil, fmt.Errorf("store: scan task run: %w", err)
|
||||
}
|
||||
runs = append(runs, run)
|
||||
}
|
||||
return runs, rows.Err()
|
||||
}
|
||||
|
||||
// TaskSettingsAll returns every override. A task with no row is absent from the map, and
|
||||
// absence means "as declared" — see TaskSettings.
|
||||
func (s *Store) TaskSettingsAll(ctx context.Context) (map[string]TaskSettings, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT task_id, enabled, interval_seconds, updated_at FROM scheduled_task_settings`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: task settings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
settings := map[string]TaskSettings{}
|
||||
for rows.Next() {
|
||||
var row TaskSettings
|
||||
if err := rows.Scan(&row.TaskID, &row.Enabled, &row.IntervalSeconds,
|
||||
&row.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan task settings: %w", err)
|
||||
}
|
||||
settings[row.TaskID] = row
|
||||
}
|
||||
return settings, rows.Err()
|
||||
}
|
||||
|
||||
// SetTaskSettings records an operator's override.
|
||||
func (s *Store) SetTaskSettings(ctx context.Context, settings TaskSettings) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO scheduled_task_settings (task_id, enabled, interval_seconds, updated_at)
|
||||
VALUES ($1, $2, $3, now())
|
||||
ON CONFLICT (task_id) DO UPDATE SET
|
||||
enabled = EXCLUDED.enabled,
|
||||
interval_seconds = EXCLUDED.interval_seconds,
|
||||
updated_at = now()`,
|
||||
settings.TaskID, settings.Enabled, settings.IntervalSeconds)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set task settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TaskLastSuccess is when each task last finished cleanly, which is what the scheduler
|
||||
// restores its clock from after a restart — without it a container replaced at 3am would
|
||||
// re-run every overnight job the moment it came up.
|
||||
func (s *Store) TaskLastSuccess(ctx context.Context) (map[string]time.Time, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT task_id, max(started_at) FROM scheduled_task_runs
|
||||
WHERE status = $1 GROUP BY task_id`, TaskSuccess)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: task last success: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
last := map[string]time.Time{}
|
||||
for rows.Next() {
|
||||
var taskID string
|
||||
var at time.Time
|
||||
if err := rows.Scan(&taskID, &at); err != nil {
|
||||
return nil, fmt.Errorf("store: scan task last success: %w", err)
|
||||
}
|
||||
last[taskID] = at
|
||||
}
|
||||
return last, rows.Err()
|
||||
}
|
||||
|
||||
// PruneTaskRuns drops run history past the retention period.
|
||||
func (s *Store) PruneTaskRuns(ctx context.Context, retention time.Duration) (int64, error) {
|
||||
if retention <= 0 {
|
||||
retention = TaskRunRetention
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`DELETE FROM scheduled_task_runs WHERE started_at < now() - $1::interval`,
|
||||
fmt.Sprintf("%d seconds", int64(retention.Seconds())))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: prune task runs: %w", err)
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// --- integration delivery history -------------------------------------------------
|
||||
|
||||
// IntegrationDelivery is one attempt to hand an event to an external service.
|
||||
type IntegrationDelivery struct {
|
||||
ID int64 `json:"id"`
|
||||
IntegrationID string `json:"integrationId"`
|
||||
EventType string `json:"eventType"`
|
||||
AttemptedAt time.Time `json:"attemptedAt"`
|
||||
Success bool `json:"success"`
|
||||
StatusCode int `json:"statusCode"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// IntegrationHealth is the pair of questions an operator actually has about a webhook:
|
||||
// is it working, and if not, what did it say. Both are "last", not "count", because a
|
||||
// destination that failed once an hour ago and has worked since is healthy.
|
||||
type IntegrationHealth struct {
|
||||
IntegrationID string `json:"integrationId"`
|
||||
LastSuccess *time.Time `json:"lastSuccess,omitempty"`
|
||||
LastFailure *time.Time `json:"lastFailure,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
Deliveries int `json:"deliveries"`
|
||||
Failures int `json:"failures"`
|
||||
}
|
||||
|
||||
func (s *Store) RecordIntegrationDelivery(ctx context.Context, delivery IntegrationDelivery) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO integration_deliveries (
|
||||
integration_id, event_type, success, status_code, duration_ms, error
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
delivery.IntegrationID, delivery.EventType, delivery.Success,
|
||||
delivery.StatusCode, delivery.DurationMS, delivery.Error)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: record integration delivery: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) IntegrationDeliveries(ctx context.Context, integrationID string, limit int) ([]IntegrationDelivery, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 25
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, integration_id, event_type, attempted_at, success, status_code,
|
||||
duration_ms, error
|
||||
FROM integration_deliveries
|
||||
WHERE ($1 = '' OR integration_id = $1)
|
||||
ORDER BY attempted_at DESC, id DESC
|
||||
LIMIT $2`, integrationID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list integration deliveries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
deliveries := []IntegrationDelivery{}
|
||||
for rows.Next() {
|
||||
var delivery IntegrationDelivery
|
||||
if err := rows.Scan(&delivery.ID, &delivery.IntegrationID, &delivery.EventType,
|
||||
&delivery.AttemptedAt, &delivery.Success, &delivery.StatusCode,
|
||||
&delivery.DurationMS, &delivery.Error); err != nil {
|
||||
return nil, fmt.Errorf("store: scan integration delivery: %w", err)
|
||||
}
|
||||
deliveries = append(deliveries, delivery)
|
||||
}
|
||||
return deliveries, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) IntegrationHealthFor(ctx context.Context, integrationID string) (IntegrationHealth, error) {
|
||||
health := IntegrationHealth{IntegrationID: integrationID}
|
||||
var lastError *string
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT max(attempted_at) FILTER (WHERE success),
|
||||
max(attempted_at) FILTER (WHERE NOT success),
|
||||
(SELECT error FROM integration_deliveries
|
||||
WHERE integration_id = $1 AND NOT success
|
||||
ORDER BY attempted_at DESC LIMIT 1),
|
||||
count(*), count(*) FILTER (WHERE NOT success)
|
||||
FROM integration_deliveries WHERE integration_id = $1`, integrationID).
|
||||
Scan(&health.LastSuccess, &health.LastFailure, &lastError,
|
||||
&health.Deliveries, &health.Failures)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return health, fmt.Errorf("store: integration health: %w", err)
|
||||
}
|
||||
if lastError != nil {
|
||||
health.LastError = *lastError
|
||||
}
|
||||
return health, nil
|
||||
}
|
||||
|
||||
// PruneIntegrationDeliveries keeps the newest `keep` attempts per integration.
|
||||
//
|
||||
// It is bounded per integration rather than by age, because the useful property of this
|
||||
// table is "the last few attempts for each destination" — an age cut would empty it
|
||||
// entirely for a webhook that fires once a month, which is the one whose last delivery an
|
||||
// operator most wants to see.
|
||||
func (s *Store) PruneIntegrationDeliveries(ctx context.Context, keep int) (int64, error) {
|
||||
if keep <= 0 {
|
||||
keep = 100
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
DELETE FROM integration_deliveries WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, row_number() OVER (
|
||||
PARTITION BY integration_id ORDER BY attempted_at DESC, id DESC
|
||||
) AS position
|
||||
FROM integration_deliveries
|
||||
) ranked WHERE position > $1
|
||||
)`, keep)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: prune integration deliveries: %w", err)
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
@@ -539,3 +539,121 @@ CREATE TABLE IF NOT EXISTS media_requests (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS media_requests_user_requested_idx
|
||||
ON media_requests (emby_user_id, requested_at DESC);
|
||||
|
||||
-- Every sign-in attempt, successful or not.
|
||||
--
|
||||
-- The sessions table above holds one row per television and is overwritten by the next
|
||||
-- sign-in, replaced when a device id moves and deleted when a set is removed — so it can
|
||||
-- say what is true now and nothing at all about what happened. This is the history: how
|
||||
-- often a set has connected, at what times, from which addresses, on which build, and
|
||||
-- whether the attempt got in. Every column records what was true at the moment of the
|
||||
-- attempt, including the ones that later change, which is why device_name and
|
||||
-- client_version are copied here rather than joined from the session.
|
||||
--
|
||||
-- A failed attempt has no emby_user_id: Emby refused the credentials, so there is no
|
||||
-- verified identity to attribute it to. The username is what was typed, and it is kept
|
||||
-- precisely because a run of failures against one name is the thing worth noticing.
|
||||
CREATE TABLE IF NOT EXISTS login_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
emby_user_id TEXT NOT NULL DEFAULT '',
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
device_name TEXT NOT NULL DEFAULT '',
|
||||
client_version TEXT NOT NULL DEFAULT '',
|
||||
client_protocol TEXT NOT NULL DEFAULT '',
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
success BOOLEAN NOT NULL,
|
||||
method TEXT NOT NULL DEFAULT 'password',
|
||||
failure_reason TEXT NOT NULL DEFAULT '',
|
||||
new_device BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS login_events_time_idx ON login_events (occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS login_events_device_time_idx
|
||||
ON login_events (device_id, occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS login_events_user_time_idx
|
||||
ON login_events (emby_user_id, occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS login_events_ip_idx ON login_events (ip_address);
|
||||
|
||||
-- Administrative events: the operational feed behind the console's notification bell.
|
||||
--
|
||||
-- Deliberately generic. A publisher supplies a type, a severity, who or what it concerns
|
||||
-- and a sentence; nothing here knows about sign-ins, devices or scheduled tasks
|
||||
-- specifically. That is what lets a service added later publish into the same feed, and
|
||||
-- what lets one integration deliver every kind of event without a case per kind.
|
||||
--
|
||||
-- read_at is a single operator's read state rather than a per-account one: the console is
|
||||
-- guarded by one shared admin token, so there is one reader by construction.
|
||||
CREATE TABLE IF NOT EXISTS admin_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
type TEXT NOT NULL,
|
||||
severity TEXT NOT NULL DEFAULT 'info',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
actor TEXT NOT NULL DEFAULT '',
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
link TEXT NOT NULL DEFAULT '',
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
read_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS admin_events_time_idx ON admin_events (occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS admin_events_unread_idx
|
||||
ON admin_events (occurred_at DESC) WHERE read_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS admin_events_type_time_idx ON admin_events (type, occurred_at DESC);
|
||||
|
||||
-- One row per run of a scheduled task.
|
||||
--
|
||||
-- The scheduler keeps its next-run time in memory because it is derived from the schedule
|
||||
-- and the clock, but what *happened* has to outlive the process: an operator asking why
|
||||
-- the overnight housekeeping did not run is asking about a container that has since been
|
||||
-- replaced. Duration is stored rather than derived so a run killed by a restart, which
|
||||
-- has a start and no finish, is distinguishable from one that took no time.
|
||||
CREATE TABLE IF NOT EXISTS scheduled_task_runs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
trigger TEXT NOT NULL DEFAULT 'schedule', -- schedule | manual | startup
|
||||
status TEXT NOT NULL, -- running | success | failed | skipped
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
finished_at TIMESTAMPTZ,
|
||||
duration_ms BIGINT NOT NULL DEFAULT 0,
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS scheduled_task_runs_task_idx
|
||||
ON scheduled_task_runs (task_id, started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS scheduled_task_runs_time_idx
|
||||
ON scheduled_task_runs (started_at DESC);
|
||||
|
||||
-- An operator's per-task overrides. A task that has never been touched has no row, and
|
||||
-- absence is "run as the code declares" — reading it the other way would leave every task
|
||||
-- disabled on the day this shipped.
|
||||
CREATE TABLE IF NOT EXISTS scheduled_task_settings (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
interval_seconds INT NOT NULL DEFAULT 0, -- 0 keeps the task's declared interval
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- What an integration actually managed to deliver.
|
||||
--
|
||||
-- Kept because the two questions an operator has about a webhook are "is it working" and
|
||||
-- "why did that one not arrive", and neither is answerable from configuration. The body
|
||||
-- is not stored: it is reconstructible from the event, and a webhook payload is the one
|
||||
-- place a URL containing a secret would otherwise come to rest in the database.
|
||||
CREATE TABLE IF NOT EXISTS integration_deliveries (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
integration_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL DEFAULT '',
|
||||
attempted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
success BOOLEAN NOT NULL,
|
||||
status_code INT NOT NULL DEFAULT 0,
|
||||
duration_ms BIGINT NOT NULL DEFAULT 0,
|
||||
error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS integration_deliveries_idx
|
||||
ON integration_deliveries (integration_id, attempted_at DESC);
|
||||
|
||||
@@ -32,10 +32,24 @@ const MDBListSettingsKey = "mdblist_settings"
|
||||
// HeroPolicy stores only Emby ids and the optional prime-card copy. Names and artwork
|
||||
// remain library data, so a metadata correction appears without rewriting operator policy.
|
||||
type HeroPolicy struct {
|
||||
PinnedItemIDs []string `json:"pinnedItemIds"`
|
||||
LegacyPinnedMovieIDs []string `json:"pinnedMovieIds,omitempty"`
|
||||
PrimeSubtitle string `json:"primeSubtitle"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
PinnedItemIDs []string `json:"pinnedItemIds"`
|
||||
LegacyPinnedMovieIDs []string `json:"pinnedMovieIds,omitempty"`
|
||||
PrimeSubtitle string `json:"primeSubtitle"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Schedules []HeroSchedule `json:"schedules"`
|
||||
}
|
||||
|
||||
// HeroSchedule is resolved by the gateway for every home response. Times are UTC RFC3339;
|
||||
// weekdays use the local calendar day (Sunday=0) and an empty list means every day.
|
||||
type HeroSchedule struct {
|
||||
ID string `json:"id"`
|
||||
ItemID string `json:"itemId"`
|
||||
StartAt time.Time `json:"startAt"`
|
||||
EndAt time.Time `json:"endAt"`
|
||||
Weekdays []int `json:"weekdays,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
|
||||
@@ -59,6 +73,32 @@ func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
|
||||
if len(runes) > 160 {
|
||||
policy.PrimeSubtitle = string(runes[:160])
|
||||
}
|
||||
cleanSchedules := make([]HeroSchedule, 0, len(policy.Schedules))
|
||||
seenSchedules := map[string]bool{}
|
||||
for _, schedule := range policy.Schedules {
|
||||
schedule.ID, schedule.ItemID, schedule.UserID = strings.TrimSpace(schedule.ID), strings.TrimSpace(schedule.ItemID), strings.TrimSpace(schedule.UserID)
|
||||
if schedule.ID == "" || schedule.ItemID == "" || seenSchedules[schedule.ID] || !schedule.EndAt.After(schedule.StartAt) {
|
||||
continue
|
||||
}
|
||||
seenSchedules[schedule.ID] = true
|
||||
if schedule.Priority < -1000 {
|
||||
schedule.Priority = -1000
|
||||
}
|
||||
if schedule.Priority > 1000 {
|
||||
schedule.Priority = 1000
|
||||
}
|
||||
weekdays := make([]int, 0, len(schedule.Weekdays))
|
||||
seenDays := map[int]bool{}
|
||||
for _, day := range schedule.Weekdays {
|
||||
if day >= 0 && day <= 6 && !seenDays[day] {
|
||||
seenDays[day] = true
|
||||
weekdays = append(weekdays, day)
|
||||
}
|
||||
}
|
||||
schedule.Weekdays = weekdays
|
||||
cleanSchedules = append(cleanSchedules, schedule)
|
||||
}
|
||||
policy.Schedules = cleanSchedules
|
||||
return policy
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user