I got this wrong first time. "Head office cannot show live video cheaply" conflated TRUE VIDEO with SEEING THE CAMERA NOW, and only the first needs WebRTC and a TURN server. The shop PC is behind a router with no inbound route, so head office cannot pull the engine's MJPEG. It can answer the agent's outbound requests, which is the shape of everything else here: the server holds a poll open, the agent asks "is anyone watching?", and pushes JPEGs up for exactly as long as somebody is. Measured on the office camera: 98 KB full frame, 20.8 KB re-encoded at 640/q60, so one watcher costs ~83 KB/s. 47 frames arrived in 12 seconds - 4 fps, as configured. The UI says "about 4 frames a second" rather than letting anyone conclude the camera stutters. Nothing is uploaded when nobody is looking, which is the whole cost argument: Publish returns false once the last viewer goes, interest lapses on a timer each viewer refreshes as it reads (so a closed tab stops the upload within seconds), one push is capped at five minutes, and the UI streams one camera at a time. LiveHub is deliberately the opposite of the arrivals Hub. There a doorbell pushes nothing because nothing may be lost; here a dropped frame is the correct outcome, so each viewer has a one-slot buffer that is overwritten - the only frame worth having is the newest, and a queue would show an ever-growing delay behind the shop instead of dropping back to live. Ownership is proved once, before anything streams: the relay is keyed on a camera id, a hub does not know whose camera it holds, and a camera id is not a secret. Verified: another tenant gets 404, no session gets 401, and an agent cannot push into another site's camera. Also fixes a bug I introduced with it - the Live button was gated on `connected`, which is head office's last report and up to two minutes stale, so it hid itself during every reconnect. "Is that camera really down?" is exactly when somebody wants to look, and a hidden control says "you cannot" where the honest answer is "here is why". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
676 lines
18 KiB
Go
676 lines
18 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"github.com/loyaly/behavision-server/internal/auth"
|
|
)
|
|
|
|
// fakeStore is an in-memory Store. Handlers are where the security decisions
|
|
// live - which tenant, which message on failure, what is echoed back - and
|
|
// those are exactly what a real database would make slow and awkward to test.
|
|
type fakeStore struct {
|
|
// Which tenant and site each camera belongs to. The live relay is keyed on
|
|
// a camera id and a hub does not know whose camera it holds, so ownership
|
|
// is proved before anything streams - and that is what these tests check.
|
|
cameraRefs map[string]cameraRef
|
|
|
|
// Camera pictures held by the server, for a deployment with no bucket.
|
|
// Keyed as written by PutCameraSnapshot (by camera_id) and as read by
|
|
// CameraSnapshot ("client/camera"), so a test has to say which it means.
|
|
snapshots map[string][]byte
|
|
snapshotRejects bool
|
|
lastSnapshotClient string
|
|
lastSnapshotSite string
|
|
|
|
mu sync.Mutex
|
|
|
|
users map[string]UserRecord // by lower-cased email
|
|
sessions map[string]*fakeSession
|
|
byAccess map[string]string // access hash hex -> session id
|
|
byRefresh map[string]string
|
|
|
|
visitors []Customer
|
|
history []VisitRow
|
|
footfall []FootfallPoint
|
|
totals Totals
|
|
sales SalesReport
|
|
sites []SiteHealth
|
|
enrolment map[string]Enrolment
|
|
|
|
// Recorded calls, so a test can assert what the handler asked for rather
|
|
// than only what it returned.
|
|
lastReport ReportQuery
|
|
lastProfile Profile
|
|
lastProfileClient string
|
|
lastPurchase PurchaseInput
|
|
audits []AuditEntry
|
|
|
|
// arrivals is the whole table; arrivalQ records what the handler asked for
|
|
// so a test can assert on the keyset window rather than only its output.
|
|
arrivals []Arrival
|
|
arrivalQ ArrivalQuery
|
|
arrivalsErr error
|
|
arrivalCalls int
|
|
|
|
pendingChecks []AgentCheckJob
|
|
checkResults []AgentCheckResult
|
|
releasedStale int
|
|
lastCodeActor string
|
|
lastCodeTTL time.Duration
|
|
lastCheckKind string
|
|
lastCheckSeconds int
|
|
|
|
cameras []Camera
|
|
agentCameras []AgentCamera
|
|
lastCameraReport AgentCameraReport
|
|
lastReportClient string
|
|
lastReportSite string
|
|
saveCameraErr error
|
|
lastSaved CameraInput
|
|
|
|
clients []ClientRow
|
|
lastNewClient NewClientInput
|
|
newClientErr error
|
|
loginTouched []string
|
|
|
|
profileErr error
|
|
purchaseErr error
|
|
forgetErr error
|
|
nextID int
|
|
|
|
agentTokens map[string]AgentPrincipal
|
|
imageKeys map[string]string
|
|
forgotten []string
|
|
}
|
|
|
|
type fakeSession struct {
|
|
id string
|
|
p auth.Principal
|
|
accessExp, refreshExp time.Time
|
|
revoked bool
|
|
}
|
|
|
|
func newFakeStore() *fakeStore {
|
|
return &fakeStore{
|
|
users: map[string]UserRecord{},
|
|
sessions: map[string]*fakeSession{},
|
|
byAccess: map[string]string{},
|
|
byRefresh: map[string]string{},
|
|
enrolment: map[string]Enrolment{},
|
|
agentTokens: map[string]AgentPrincipal{},
|
|
imageKeys: map[string]string{},
|
|
}
|
|
}
|
|
|
|
func (f *fakeStore) addUser(email, password string, rec UserRecord) {
|
|
hash, err := auth.HashPassword(password)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
rec.Email = email
|
|
rec.PasswordHash = hash
|
|
rec.Found = true
|
|
if rec.ID == "" {
|
|
rec.ID = "user-" + email
|
|
}
|
|
if rec.Role == "" {
|
|
rec.Role = "manager"
|
|
}
|
|
f.users[auth.NormalizeEmail(email)] = rec
|
|
}
|
|
|
|
func (f *fakeStore) UserByEmail(_ context.Context, email string) (UserRecord, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
u, ok := f.users[email]
|
|
if !ok {
|
|
return UserRecord{Found: false}, nil
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func (f *fakeStore) TouchUserLogin(_ context.Context, id string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.loginTouched = append(f.loginTouched, id)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) CreateSession(_ context.Context, n NewSession) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.nextID++
|
|
id := "sess-" + itoa(f.nextID)
|
|
var rec UserRecord
|
|
for _, u := range f.users {
|
|
if u.ID == n.UserID {
|
|
rec = u
|
|
}
|
|
}
|
|
s := &fakeSession{
|
|
id: id,
|
|
p: auth.Principal{
|
|
UserID: n.UserID, SessionID: id, ClientID: n.ClientID,
|
|
ClientName: rec.ClientName, Email: rec.Email,
|
|
FullName: rec.FullName, Role: rec.Role,
|
|
},
|
|
accessExp: n.AccessExpiry, refreshExp: n.RefreshExp,
|
|
}
|
|
f.sessions[id] = s
|
|
f.byAccess[hex.EncodeToString(n.AccessHash)] = id
|
|
f.byRefresh[hex.EncodeToString(n.RefreshHash)] = id
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) lookup(index map[string]string, hash []byte, refresh bool) (
|
|
auth.Principal, time.Time, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
id, ok := index[hex.EncodeToString(hash)]
|
|
if !ok {
|
|
return auth.Principal{}, time.Time{}, auth.ErrNoSession
|
|
}
|
|
s := f.sessions[id]
|
|
if s == nil || s.revoked {
|
|
return auth.Principal{}, time.Time{}, auth.ErrNoSession
|
|
}
|
|
if refresh {
|
|
return s.p, s.refreshExp, nil
|
|
}
|
|
return s.p, s.accessExp, nil
|
|
}
|
|
|
|
func (f *fakeStore) SessionByAccess(_ context.Context, h []byte) (auth.Principal, time.Time, error) {
|
|
return f.lookup(f.byAccess, h, false)
|
|
}
|
|
|
|
func (f *fakeStore) SessionByRefresh(_ context.Context, h []byte) (auth.Principal, time.Time, error) {
|
|
return f.lookup(f.byRefresh, h, true)
|
|
}
|
|
|
|
func (f *fakeStore) RotateSession(_ context.Context, id string, n NewSession) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
s := f.sessions[id]
|
|
if s == nil || s.revoked {
|
|
return auth.ErrNoSession
|
|
}
|
|
// Mirrors the real store: the old hashes stop resolving the moment the new
|
|
// ones are written.
|
|
for k, v := range f.byAccess {
|
|
if v == id {
|
|
delete(f.byAccess, k)
|
|
}
|
|
}
|
|
for k, v := range f.byRefresh {
|
|
if v == id {
|
|
delete(f.byRefresh, k)
|
|
}
|
|
}
|
|
f.byAccess[hex.EncodeToString(n.AccessHash)] = id
|
|
f.byRefresh[hex.EncodeToString(n.RefreshHash)] = id
|
|
s.accessExp, s.refreshExp = n.AccessExpiry, n.RefreshExp
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) RevokeSession(_ context.Context, id string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if s := f.sessions[id]; s != nil {
|
|
s.revoked = true
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) Footfall(_ context.Context, q ReportQuery) ([]FootfallPoint, Totals, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.lastReport = q
|
|
return f.footfall, f.totals, nil
|
|
}
|
|
|
|
func (f *fakeStore) Conversion(_ context.Context, q ReportQuery) (SalesReport, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.lastReport = q
|
|
return f.sales, nil
|
|
}
|
|
|
|
func (f *fakeStore) SiteHealth(_ context.Context, _ string) ([]SiteHealth, error) {
|
|
return f.sites, nil
|
|
}
|
|
|
|
func (f *fakeStore) SearchVisitors(_ context.Context, clientID, q string, limit int) (
|
|
[]Customer, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.lastReport = ReportQuery{ClientID: clientID}
|
|
if limit < len(f.visitors) {
|
|
return f.visitors[:limit], nil
|
|
}
|
|
return f.visitors, nil
|
|
}
|
|
|
|
func (f *fakeStore) VisitorHistory(_ context.Context, _, _ string, _ int) ([]VisitRow, error) {
|
|
return f.history, nil
|
|
}
|
|
|
|
// Arrivals fakes the keyset window in memory: rows are held oldest-first, a
|
|
// cursor slices past it, and no cursor returns the newest Limit - the same
|
|
// contract the SQL implements, so a handler test that passes here is testing
|
|
// the handler and not a stub that is easier than the real thing.
|
|
func (f *fakeStore) Arrivals(_ context.Context, q ArrivalQuery) ([]Arrival, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.arrivalQ = q
|
|
f.arrivalCalls++
|
|
if f.arrivalsErr != nil {
|
|
return nil, f.arrivalsErr
|
|
}
|
|
rows := make([]Arrival, 0, len(f.arrivals))
|
|
for _, a := range f.arrivals {
|
|
if q.SiteID != "" && a.SiteID != q.SiteID {
|
|
continue
|
|
}
|
|
if q.AfterSeq != nil && a.Seq <= *q.AfterSeq {
|
|
continue
|
|
}
|
|
rows = append(rows, a)
|
|
}
|
|
if q.AfterSeq == nil && len(rows) > q.Limit {
|
|
// No cursor: the newest window, matching the real query.
|
|
rows = rows[len(rows)-q.Limit:]
|
|
} else if len(rows) > q.Limit {
|
|
rows = rows[:q.Limit]
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
func (f *fakeStore) SaveProfile(_ context.Context, clientID string, p Profile, _ string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.lastProfile, f.lastProfileClient = p, clientID
|
|
return f.profileErr
|
|
}
|
|
|
|
func (f *fakeStore) RecordPurchase(_ context.Context, _ string, p PurchaseInput, _ string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.lastPurchase = p
|
|
return f.purchaseErr
|
|
}
|
|
|
|
func (f *fakeStore) RedeemEnrolment(_ context.Context, hash []byte) (Enrolment, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
en, ok := f.enrolment[hex.EncodeToString(hash)]
|
|
if !ok {
|
|
return Enrolment{}, errors.New("unknown token")
|
|
}
|
|
// Single use, like the real UPDATE.
|
|
delete(f.enrolment, hex.EncodeToString(hash))
|
|
return en, nil
|
|
}
|
|
|
|
func (f *fakeStore) Cameras(_ context.Context, _, siteID string) ([]Camera, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []Camera
|
|
for _, c := range f.cameras {
|
|
if siteID == "" || c.SiteID == siteID {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) CameraByID(_ context.Context, _, id string) (Camera, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for _, c := range f.cameras {
|
|
if c.ID == id {
|
|
return c, nil
|
|
}
|
|
}
|
|
return Camera{}, errors.New("no rows in result set")
|
|
}
|
|
|
|
func (f *fakeStore) SaveCamera(_ context.Context, _, siteID, cameraID string,
|
|
in CameraInput) (Camera, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.lastSaved = in
|
|
if f.saveCameraErr != nil {
|
|
return Camera{}, f.saveCameraErr
|
|
}
|
|
// A real-shaped uuid: the handlers check the shape before touching SQL, so
|
|
// a placeholder id would exercise the 404 path instead of the one under
|
|
// test.
|
|
cam := Camera{ID: fakeCameraUUID(cameraID), SiteID: siteID, CameraID: cameraID,
|
|
Port: 554, Path: "/", MaxWidth: 1280, Enabled: true, Revision: 1}
|
|
if in.Label != nil {
|
|
cam.Label = *in.Label
|
|
}
|
|
if in.Host != nil {
|
|
cam.Host = *in.Host
|
|
}
|
|
if in.Username != nil {
|
|
cam.Username = *in.Username
|
|
}
|
|
cam.HasPassword = in.Password != nil && *in.Password != ""
|
|
f.cameras = append(f.cameras, cam)
|
|
return cam, nil
|
|
}
|
|
|
|
// fakeCameraUUID derives a stable uuid-shaped id from a camera name so tests
|
|
// can address a camera they just created without reading the response.
|
|
func fakeCameraUUID(cameraID string) string {
|
|
sum := sha256.Sum256([]byte(cameraID))
|
|
h := hex.EncodeToString(sum[:16])
|
|
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]
|
|
}
|
|
|
|
func (f *fakeStore) DeleteCamera(_ context.Context, _, id string) (Camera, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for i, c := range f.cameras {
|
|
if c.ID == id {
|
|
f.cameras = append(f.cameras[:i], f.cameras[i+1:]...)
|
|
return c, nil
|
|
}
|
|
}
|
|
return Camera{}, errors.New("no rows in result set")
|
|
}
|
|
|
|
func (f *fakeStore) AgentCameras(_ context.Context, _ string) ([]AgentCamera, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.agentCameras, nil
|
|
}
|
|
|
|
func (f *fakeStore) ApplyAgentReport(_ context.Context, clientID, siteID string,
|
|
rep AgentCameraReport) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.lastCameraReport = rep
|
|
f.lastReportClient, f.lastReportSite = clientID, siteID
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) IssueEnrolmentCode(_ context.Context, clientID, siteID,
|
|
actorID, label string, ttl time.Duration) (EnrolmentCode, error) {
|
|
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for _, si := range f.sites {
|
|
if si.SiteID == siteID {
|
|
f.lastCodeActor, f.lastCodeTTL = actorID, ttl
|
|
return EnrolmentCode{
|
|
Code: "ABCDEF-123456-GHIJKL-789012", SiteID: siteID,
|
|
SiteName: si.Name, Label: label,
|
|
ExpiresAt: time.Now().Add(ttl).UTC(),
|
|
}, nil
|
|
}
|
|
}
|
|
return EnrolmentCode{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (f *fakeStore) RequestCheck(_ context.Context, _, id, kind string, seconds int) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for i := range f.cameras {
|
|
if f.cameras[i].ID == id {
|
|
f.cameras[i].Check = CameraCheck{Kind: kind, Seconds: seconds, State: "requested"}
|
|
f.lastCheckKind, f.lastCheckSeconds = kind, seconds
|
|
return nil
|
|
}
|
|
}
|
|
return errors.New("no rows in result set")
|
|
}
|
|
|
|
func (f *fakeStore) ClaimChecks(_ context.Context, _ string) ([]AgentCheckJob, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := f.pendingChecks
|
|
f.pendingChecks = nil // claimed once, like the real UPDATE ... RETURNING
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) RecordCheckResult(_ context.Context, _ string, res AgentCheckResult) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.checkResults = append(f.checkResults, res)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) ReleaseStaleChecks(_ context.Context, _ time.Duration) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.releasedStale++
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) ListClients(_ context.Context) ([]ClientRow, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.clients, nil
|
|
}
|
|
|
|
func (f *fakeStore) CreateClientWithOwner(_ context.Context, in NewClientInput) (
|
|
NewClientResult, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.lastNewClient = in
|
|
if f.newClientErr != nil {
|
|
return NewClientResult{}, f.newClientErr
|
|
}
|
|
pw := in.Password
|
|
if pw == "" {
|
|
pw = "generated-password"
|
|
}
|
|
return NewClientResult{ClientID: "new-client-id", Slug: in.Slug,
|
|
OwnerEmail: in.OwnerEmail, Password: pw}, nil
|
|
}
|
|
|
|
func (f *fakeStore) Audit(_ context.Context, e AuditEntry) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.audits = append(f.audits, e)
|
|
}
|
|
|
|
func itoa(n int) string {
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
var b []byte
|
|
for n > 0 {
|
|
b = append([]byte{byte('0' + n%10)}, b...)
|
|
n /= 10
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// -- images and agents ------------------------------------------------------
|
|
|
|
func (f *fakeStore) SetAgentAPIToken(_ context.Context, agentID string, hash []byte) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.agentTokens == nil {
|
|
f.agentTokens = map[string]AgentPrincipal{}
|
|
}
|
|
f.agentTokens[hex.EncodeToString(hash)] = AgentPrincipal{
|
|
AgentID: agentID, ClientID: "client-acme", SiteID: "site-1",
|
|
Slug: "acme.store1", Client: "acme", Site: "store1",
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// addAgent registers a plaintext agent token, hashed the way the middleware
|
|
// will look it up.
|
|
func (f *fakeStore) addAgent(token string, ap AgentPrincipal) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.agentTokens[hex.EncodeToString(auth.HashToken(token))] = ap
|
|
}
|
|
|
|
func (f *fakeStore) AgentByToken(_ context.Context, hash []byte) (AgentPrincipal, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
ap, ok := f.agentTokens[hex.EncodeToString(hash)]
|
|
if !ok {
|
|
return AgentPrincipal{}, errors.New("no such agent")
|
|
}
|
|
return ap, nil
|
|
}
|
|
|
|
func (f *fakeStore) VisitorImageKey(_ context.Context, _, visitorID string) (string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.imageKeys[visitorID], nil
|
|
}
|
|
|
|
func (f *fakeStore) VisitorImageKeys(_ context.Context, _, visitorID string) ([]string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if k := f.imageKeys[visitorID]; k != "" {
|
|
return []string{k}, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeStore) ForgetVisitor(_ context.Context, _, visitorID string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.forgetErr != nil {
|
|
return f.forgetErr
|
|
}
|
|
f.forgotten = append(f.forgotten, visitorID)
|
|
delete(f.imageKeys, visitorID)
|
|
return nil
|
|
}
|
|
|
|
// fakeBlob records what the handlers asked storage to do. Deleting is the part
|
|
// worth recording: an erasure that reports success without removing the object
|
|
// is the failure this whole path exists to prevent.
|
|
type fakeBlob struct {
|
|
mu sync.Mutex
|
|
deleted []string
|
|
presigns []string
|
|
failNext error
|
|
}
|
|
|
|
func (b *fakeBlob) Key(client, site, objectID string, at time.Time) string {
|
|
return fmt.Sprintf("behavision/%s/%s/%04d/%02d/%02d/%s.jpg",
|
|
client, site, at.Year(), int(at.Month()), at.Day(), objectID)
|
|
}
|
|
|
|
func (b *fakeBlob) PresignPut(key string, _ time.Duration) (string, http.Header, error) {
|
|
h := http.Header{}
|
|
h.Set("x-amz-acl", "private")
|
|
h.Set("Content-Type", "image/jpeg")
|
|
return "https://bucket.example.com/" + key + "?X-Amz-Signature=fake", h, nil
|
|
}
|
|
|
|
func (b *fakeBlob) PresignGet(key string, _ time.Duration) (string, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.presigns = append(b.presigns, key)
|
|
return "https://bucket.example.com/" + key + "?X-Amz-Signature=fake", nil
|
|
}
|
|
|
|
func (b *fakeBlob) Delete(_ context.Context, key string) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.failNext != nil {
|
|
err := b.failNext
|
|
b.failNext = nil
|
|
return err
|
|
}
|
|
b.deleted = append(b.deleted, key)
|
|
return nil
|
|
}
|
|
|
|
// ------------------------------------------------------- camera snapshots --
|
|
|
|
func (f *fakeStore) PutCameraSnapshot(_ context.Context,
|
|
clientID, siteID, cameraID string, jpeg []byte) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.snapshots == nil {
|
|
f.snapshots = map[string][]byte{}
|
|
}
|
|
if f.snapshotRejects {
|
|
return ErrNoSnapshot
|
|
}
|
|
f.lastSnapshotClient, f.lastSnapshotSite = clientID, siteID
|
|
f.snapshots[cameraID] = append([]byte(nil), jpeg...)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) CameraSnapshot(_ context.Context, clientID, cameraID string) (
|
|
[]byte, time.Time, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
img, ok := f.snapshots[clientID+"/"+cameraID]
|
|
if !ok {
|
|
return nil, time.Time{}, ErrNoSnapshot
|
|
}
|
|
return img, time.Unix(1756900000, 0).UTC(), nil
|
|
}
|
|
|
|
// ------------------------------------------------------------ live relay --
|
|
|
|
func (f *fakeStore) CameraRef(_ context.Context, clientID, cameraID string) (string, string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
ref, ok := f.cameraRefs[cameraID]
|
|
if !ok || ref.client != clientID {
|
|
return "", "", ErrNoSnapshot
|
|
}
|
|
return ref.site, ref.engineID, nil
|
|
}
|
|
|
|
func (f *fakeStore) CameraRefBySite(_ context.Context, siteID, cameraID string) (string, string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
ref, ok := f.cameraRefs[cameraID]
|
|
if !ok || ref.site != siteID {
|
|
return "", "", ErrNoSnapshot
|
|
}
|
|
return ref.site, ref.engineID, nil
|
|
}
|
|
|
|
func (f *fakeStore) SiteCameraIDs(_ context.Context, siteID string) ([]string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []string
|
|
for id, ref := range f.cameraRefs {
|
|
if ref.site == siteID {
|
|
out = append(out, id)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// addCameraRef registers a camera so ownership checks have something to check.
|
|
func (f *fakeStore) addCameraRef(id, client, site, engineID string) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.cameraRefs == nil {
|
|
f.cameraRefs = map[string]cameraRef{}
|
|
}
|
|
f.cameraRefs[id] = cameraRef{client: client, site: site, engineID: engineID}
|
|
}
|
|
|
|
type cameraRef struct{ client, site, engineID string }
|