265 lines
10 KiB
Go
265 lines
10 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
|
)
|
|
|
|
// displayName is what an event summary calls somebody or something that did not say.
|
|
// A sentence reading " signed in on " is worse than one naming the gap.
|
|
func displayName(value string) string {
|
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
return "an unnamed device"
|
|
}
|
|
|
|
// recordLogin writes one attempt to the durable history.
|
|
//
|
|
// Detached from the request context and fire-and-forget, for the reason every audit write
|
|
// on a request path is: a sign-in that succeeded must not be undone because the history
|
|
// row could not be written, and a sign-in being refused is already being refused. The
|
|
// address and the build are filled in here rather than by each caller, so an attempt
|
|
// recorded from a new route cannot quietly omit them.
|
|
func (s *Server) recordLogin(r *http.Request, event store.LoginEvent) {
|
|
if s.store == nil {
|
|
return
|
|
}
|
|
if event.IPAddress == "" {
|
|
origin := s.resolveClientIP(r)
|
|
event.IPAddress = origin.String()
|
|
// One line per sign-in attempt, so an operator can see which header the address
|
|
// came from when a reverse proxy is in front of the gateway. Debug keeps it out
|
|
// of the ordinary log while still being there when the question is asked.
|
|
s.log.Debug("resolved login client ip",
|
|
"ip", origin.String(), "via", origin.Via, "device_id", event.DeviceID)
|
|
}
|
|
if event.ClientVersion == "" {
|
|
event.ClientVersion = clientVersion(r)
|
|
}
|
|
if event.ClientProtocol == "" {
|
|
event.ClientProtocol = clientProtocol(r)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second)
|
|
go func() {
|
|
defer cancel()
|
|
if err := s.store.RecordLogin(ctx, event); err != nil {
|
|
s.log.Warn("login not recorded",
|
|
"device_id", event.DeviceID, "success", event.Success, "error", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// loginFilterFrom reads the console's filter bar off the query string.
|
|
//
|
|
// Every control is optional and an absent one means "any", which is what lets the page
|
|
// combine them freely — user and device and address and a date range — without the server
|
|
// needing a case per combination. Dates arrive as RFC3339 or as a plain YYYY-MM-DD, and a
|
|
// plain date is read in the *household's* zone: an operator asking for "13 August" means
|
|
// their own day, and reading it as UTC would silently shift the window by twelve hours.
|
|
func (s *Server) loginFilterFrom(r *http.Request) store.LoginFilter {
|
|
query := r.URL.Query()
|
|
filter := store.LoginFilter{
|
|
EmbyUserID: strings.TrimSpace(query.Get("user")),
|
|
DeviceID: strings.TrimSpace(query.Get("device")),
|
|
IPAddress: strings.TrimSpace(query.Get("ip")),
|
|
Query: strings.TrimSpace(query.Get("q")),
|
|
Outcome: strings.TrimSpace(query.Get("outcome")),
|
|
Method: strings.TrimSpace(query.Get("method")),
|
|
Limit: queryInt(r, "limit", 100, 500),
|
|
Offset: queryInt(r, "offset", 0, 100000),
|
|
}
|
|
filter.From = s.parseFilterDate(query.Get("from"), false)
|
|
filter.To = s.parseFilterDate(query.Get("to"), true)
|
|
if days := queryInt(r, "days", 0, 365); days > 0 && filter.From.IsZero() {
|
|
filter.From = time.Now().Add(-time.Duration(days) * 24 * time.Hour)
|
|
}
|
|
return filter
|
|
}
|
|
|
|
// parseFilterDate reads one end of a range. endOfDay pushes a plain date to the following
|
|
// midnight, because "to: 13 August" means through the end of the 13th — the alternative
|
|
// silently excludes everything that happened on the day the operator asked about.
|
|
func (s *Server) parseFilterDate(raw string, endOfDay bool) time.Time {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return time.Time{}
|
|
}
|
|
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
|
|
return parsed
|
|
}
|
|
location := s.householdLocation()
|
|
parsed, err := time.ParseInLocation("2006-01-02", raw, location)
|
|
if err != nil {
|
|
return time.Time{}
|
|
}
|
|
if endOfDay {
|
|
return parsed.AddDate(0, 0, 1)
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
// zoneName is the household's timezone, which every day-grouping query needs. Postgres is
|
|
// asked to do the grouping in it rather than the console doing it in the browser's zone,
|
|
// for the reason store.LoginDays gives.
|
|
func (s *Server) zoneName() string {
|
|
return s.householdTimezoneName()
|
|
}
|
|
|
|
type adminLoginsResponse struct {
|
|
Events []store.LoginEvent `json:"events"`
|
|
Total int `json:"total"`
|
|
Limit int `json:"limit"`
|
|
Offset int `json:"offset"`
|
|
Totals store.LoginTotals `json:"totals"`
|
|
Days []store.LoginDay `json:"days"`
|
|
Addresses []store.LoginAddress `json:"addresses"`
|
|
Users []store.KnownUser `json:"users"`
|
|
Retention int `json:"retentionDays"`
|
|
Zone string `json:"timezone"`
|
|
}
|
|
|
|
// handleAdminLogins is the login history page: the filtered log, the totals over the same
|
|
// filter, the daily shape and where the attempts came from, in one response.
|
|
//
|
|
// One response rather than four routes because they are four views of one filter, and a
|
|
// page that fetched them separately could show a chart of one window beside a table of
|
|
// another the moment a filter changed between requests.
|
|
func (s *Server) handleAdminLogins(w http.ResponseWriter, r *http.Request) {
|
|
filter := s.loginFilterFrom(r)
|
|
page, err := s.store.LoginEvents(r.Context(), filter)
|
|
if err != nil {
|
|
s.log.Error("login history failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "could not read the login history")
|
|
return
|
|
}
|
|
response := adminLoginsResponse{
|
|
Events: page.Events, Total: page.Total, Limit: page.Limit, Offset: page.Offset,
|
|
Retention: int(store.LoginRetention / (24 * time.Hour)), Zone: s.zoneName(),
|
|
Days: []store.LoginDay{}, Addresses: []store.LoginAddress{},
|
|
Users: []store.KnownUser{},
|
|
}
|
|
// The three summaries are decoration on the table: a failure in any of them costs a
|
|
// chart, never the history the operator opened the page for.
|
|
if totals, err := s.store.LoginTotals(r.Context(), filter); err == nil {
|
|
response.Totals = totals
|
|
}
|
|
if days, err := s.store.LoginDays(r.Context(), filter, s.zoneName()); err == nil {
|
|
response.Days = days
|
|
}
|
|
if addresses, err := s.store.LoginAddresses(r.Context(), filter, 25); err == nil {
|
|
response.Addresses = addresses
|
|
}
|
|
if users, err := s.store.KnownUsers(r.Context()); err == nil {
|
|
response.Users = users
|
|
}
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
type adminLoginDevicesResponse struct {
|
|
Devices []store.LoginDeviceSummary `json:"devices"`
|
|
Totals store.LoginTotals `json:"totals"`
|
|
Users []store.KnownUser `json:"users"`
|
|
Zone string `json:"timezone"`
|
|
Retention int `json:"retentionDays"`
|
|
}
|
|
|
|
// handleAdminLoginDevices is the devices table, built from the history rather than from
|
|
// the session list — see store.LoginDeviceSummaries for why a removed television still
|
|
// belongs on it.
|
|
func (s *Server) handleAdminLoginDevices(w http.ResponseWriter, r *http.Request) {
|
|
filter := s.loginFilterFrom(r)
|
|
devices, err := s.store.LoginDeviceSummaries(r.Context(), filter, s.zoneName())
|
|
if err != nil {
|
|
s.log.Error("login device summary failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "could not summarise devices")
|
|
return
|
|
}
|
|
response := adminLoginDevicesResponse{
|
|
Devices: devices, Zone: s.zoneName(), Users: []store.KnownUser{},
|
|
Retention: int(store.LoginRetention / (24 * time.Hour)),
|
|
}
|
|
if totals, err := s.store.LoginTotals(r.Context(), filter); err == nil {
|
|
response.Totals = totals
|
|
}
|
|
if users, err := s.store.KnownUsers(r.Context()); err == nil {
|
|
response.Users = users
|
|
}
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
type adminDeviceDetailResponse struct {
|
|
DeviceID string `json:"deviceId"`
|
|
Summary *store.LoginDeviceSummary `json:"summary,omitempty"`
|
|
Events []store.LoginEvent `json:"events"`
|
|
Total int `json:"total"`
|
|
Days []store.LoginDay `json:"days"`
|
|
Addresses []store.LoginAddress `json:"addresses"`
|
|
Versions []store.DeviceVersion `json:"versions"`
|
|
Zone string `json:"timezone"`
|
|
}
|
|
|
|
// handleAdminDeviceDetail answers the question the whole feature exists for: how many
|
|
// times did this television connect, at what times, and from which addresses.
|
|
func (s *Server) handleAdminDeviceDetail(w http.ResponseWriter, r *http.Request) {
|
|
deviceID := strings.TrimSpace(r.PathValue("deviceID"))
|
|
if deviceID == "" {
|
|
writeError(w, http.StatusBadRequest, "device id is required")
|
|
return
|
|
}
|
|
filter := s.loginFilterFrom(r)
|
|
// The path wins over the query string: this route is *about* one device, and a filter
|
|
// naming another would render a page describing something other than its own URL.
|
|
filter.DeviceID = deviceID
|
|
|
|
page, err := s.store.LoginEvents(r.Context(), filter)
|
|
if err != nil {
|
|
s.log.Error("device login history failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "could not read the device history")
|
|
return
|
|
}
|
|
response := adminDeviceDetailResponse{
|
|
DeviceID: deviceID, Events: page.Events, Total: page.Total,
|
|
Zone: s.zoneName(), Days: []store.LoginDay{},
|
|
Addresses: []store.LoginAddress{}, Versions: []store.DeviceVersion{},
|
|
}
|
|
// The summary is over the *unfiltered* history for this device, so the headline
|
|
// counts describe the television rather than whatever window is being looked at.
|
|
if summaries, err := s.store.LoginDeviceSummaries(
|
|
r.Context(), store.LoginFilter{DeviceID: deviceID}, s.zoneName(),
|
|
); err == nil && len(summaries) > 0 {
|
|
response.Summary = &summaries[0]
|
|
}
|
|
if days, err := s.store.LoginDays(r.Context(), filter, s.zoneName()); err == nil {
|
|
response.Days = days
|
|
}
|
|
if addresses, err := s.store.LoginAddresses(r.Context(), filter, 25); err == nil {
|
|
response.Addresses = addresses
|
|
}
|
|
if history, err := s.store.DeviceVersions(r.Context(), []string{deviceID}); err == nil {
|
|
if versions := history[deviceID]; versions != nil {
|
|
response.Versions = versions
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// queryInt64s reads a repeated or comma-separated integer parameter, which is how the
|
|
// notification feed is told which events to mark read.
|
|
func queryInt64s(r *http.Request, name string) []int64 {
|
|
values := []int64{}
|
|
for _, raw := range r.URL.Query()[name] {
|
|
for _, part := range strings.Split(raw, ",") {
|
|
if parsed, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64); err == nil {
|
|
values = append(values, parsed)
|
|
}
|
|
}
|
|
}
|
|
return values
|
|
}
|