0.1.38 gateway

This commit is contained in:
ponzischeme89
2026-08-14 09:40:03 +12:00
parent abc392d30b
commit 5e2ed3d12e
2847 changed files with 1072928 additions and 3783 deletions
+107
View File
@@ -3,10 +3,13 @@ package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/adminevents"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
@@ -82,10 +85,47 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
"username", req.Username, "device", req.DeviceName, "device_id", req.DeviceID,
"reason", "emby refused the credentials",
)
// A refused attempt has no verified identity, so it carries the name that was
// typed and no user id. It is recorded precisely because a run of these against
// one name is the thing worth noticing, and nothing else in the gateway keeps it.
s.recordLogin(r, store.LoginEvent{
Username: req.Username,
DeviceID: req.DeviceID,
DeviceName: req.DeviceName,
Success: false,
Method: store.LoginMethodPassword,
// Emby's reason is deliberately not carried through: it distinguishes
// "no such user" from "wrong password", which is more than an operator's
// console should restate about somebody else's failed attempt.
FailureReason: "credentials refused",
})
s.publishAdmin(r.Context(), adminevents.Event{
Type: adminevents.TypeLoginFailed,
Severity: adminevents.SeverityWarning,
Title: "Sign-in refused",
Summary: fmt.Sprintf("%s was refused on %s",
displayName(req.Username), displayName(req.DeviceName)),
Actor: req.Username, Target: req.DeviceName,
Link: "/admin/logins",
Metadata: adminevents.Meta(map[string]any{
"deviceId": req.DeviceID, "ip": requestClientIP(r),
}),
})
writeError(w, http.StatusUnauthorized, "sign-in failed")
return
}
// Asked before the attempt is recorded, so this sign-in cannot answer for itself:
// "new device registered" is only distinguishable from every later sign-in by the
// same set if the history is consulted while it still predates this one.
knownDevice, lookupErr := s.store.DeviceHasLoggedIn(r.Context(), auth.User.ID, req.DeviceID)
if lookupErr != nil {
s.loggerFor(r.Context()).Warn("device history lookup failed", "error", lookupErr)
// Assume known. Announcing a device as new because a query failed is a claim; not
// announcing one is a missed line.
knownDevice = true
}
token, err := newToken()
if err != nil {
s.log.Error("token generation failed", "error", err)
@@ -143,6 +183,45 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
"replaced_session", len(created.ReplacedHash) > 0,
)
address := requestClientIP(r)
s.recordLogin(r, store.LoginEvent{
EmbyUserID: sess.EmbyUserID, Username: sess.Username,
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
ClientVersion: sess.ClientVersion, ClientProtocol: sess.ClientProtocol,
Success: true, Method: store.LoginMethodPassword, NewDevice: !knownDevice,
})
// A television arriving for the first time and one signing in again are the same
// request and different news, which is why they are different event types rather than
// one type with a flag: an operator subscribing a Discord channel to new devices is
// asking for the rare one, and would not want the other.
if knownDevice {
s.publishAdmin(r.Context(), adminevents.Event{
Type: adminevents.TypeLogin,
Title: "Signed in",
Summary: fmt.Sprintf("%s signed in on %s",
displayName(sess.Username), displayName(sess.DeviceName)),
Actor: sess.Username, Target: sess.DeviceName,
Link: "/admin/devices/" + url.PathEscape(sess.DeviceID),
Metadata: adminevents.Meta(map[string]any{
"deviceId": sess.DeviceID, "userId": sess.EmbyUserID,
"ip": address, "version": sess.ClientVersion,
}),
})
} else {
s.publishAdmin(r.Context(), adminevents.Event{
Type: adminevents.TypeDeviceRegistered,
Title: "New device registered",
Summary: fmt.Sprintf("%s signed in on %s for the first time",
displayName(sess.Username), displayName(sess.DeviceName)),
Actor: sess.Username, Target: sess.DeviceName,
Link: "/admin/devices/" + url.PathEscape(sess.DeviceID),
Metadata: adminevents.Meta(map[string]any{
"deviceId": sess.DeviceID, "userId": sess.EmbyUserID,
"ip": address, "version": sess.ClientVersion,
}),
})
}
writeJSON(w, http.StatusOK, loginResponse{
Token: token, UserID: sess.EmbyUserID, Username: sess.Username, ServerID: sess.ServerID,
})
@@ -155,6 +234,15 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, sess store
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(sess.TokenHash)))
_ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID)
s.loggerFor(r.Context()).Info("signed out", "device_id", sess.DeviceID)
s.publishAdmin(r.Context(), adminevents.Event{
Type: adminevents.TypeLogout,
Title: "Signed out",
Summary: fmt.Sprintf("%s signed out on %s",
displayName(sess.Username), displayName(sess.DeviceName)),
Actor: sess.Username, Target: sess.DeviceName,
Link: "/admin/devices/" + url.PathEscape(sess.DeviceID),
Metadata: adminevents.Meta(map[string]any{"deviceId": sess.DeviceID}),
})
w.WriteHeader(http.StatusNoContent)
}
@@ -212,6 +300,16 @@ func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request, curr
// A device disappearing from a household is worth a line: the next thing that TV
// reports is a sign-in, and the two together explain each other.
s.loggerFor(r.Context()).Info("device signed out remotely", "removed_device_id", deviceID)
s.publishAdmin(r.Context(), adminevents.Event{
Type: adminevents.TypeDeviceRemoved,
Severity: adminevents.SeverityWarning,
Title: "Device removed",
Summary: fmt.Sprintf("%s removed a device from their account",
displayName(current.Username)),
Actor: current.Username, Target: deviceID,
Link: "/admin/devices",
Metadata: adminevents.Meta(map[string]any{"deviceId": deviceID}),
})
w.WriteHeader(http.StatusNoContent)
}
@@ -289,5 +387,14 @@ func (s *Server) handleRenameDevice(w http.ResponseWriter, r *http.Request, curr
s.loggerFor(r.Context()).Info("device renamed",
"renamed_device_id", deviceID, "new_name", req.DeviceName,
)
s.publishAdmin(r.Context(), adminevents.Event{
Type: adminevents.TypeDeviceRenamed,
Title: "Device renamed",
Summary: fmt.Sprintf("%s renamed a device to %s",
displayName(current.Username), req.DeviceName),
Actor: current.Username, Target: req.DeviceName,
Link: "/admin/devices/" + url.PathEscape(deviceID),
Metadata: adminevents.Meta(map[string]any{"deviceId": deviceID}),
})
w.WriteHeader(http.StatusNoContent)
}