This commit is contained in:
Daniel Ponte 2025-01-22 14:15:53 -05:00
parent 76a2214377
commit f1a5f70a79
12 changed files with 201 additions and 85 deletions

View file

@ -53,40 +53,6 @@ func (q *Queries) DeleteShare(ctx context.Context, id string) error {
return err
}
const getIncidentTalkgroups = `-- name: GetIncidentTalkgroups :many
SELECT DISTINCT
c.system,
c.talkgroup
FROM incidents_calls ic
JOIN calls c ON (c.id = ic.call_id AND c.call_date = ic.call_date)
WHERE ic.incident_id = $1
`
type GetIncidentTalkgroupsRow struct {
System int `json:"system"`
Talkgroup int `json:"talkgroup"`
}
func (q *Queries) GetIncidentTalkgroups(ctx context.Context, incidentID uuid.UUID) ([]GetIncidentTalkgroupsRow, error) {
rows, err := q.db.Query(ctx, getIncidentTalkgroups, incidentID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetIncidentTalkgroupsRow
for rows.Next() {
var i GetIncidentTalkgroupsRow
if err := rows.Scan(&i.System, &i.Talkgroup); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getShare = `-- name: GetShare :one
SELECT
id,

View file

@ -10,6 +10,7 @@ import (
"dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/alerting/rules"
"github.com/google/uuid"
)
const addLearnedTalkgroup = `-- name: AddLearnedTalkgroup :one
@ -117,6 +118,40 @@ func (q *Queries) GetAllTalkgroupTags(ctx context.Context) ([]string, error) {
return items, nil
}
const getIncidentTalkgroups = `-- name: GetIncidentTalkgroups :many
SELECT DISTINCT
c.system,
c.talkgroup
FROM incidents_calls ic
JOIN calls c ON (c.id = ic.call_id AND c.call_date = ic.call_date)
WHERE ic.incident_id = $1
`
type GetIncidentTalkgroupsRow struct {
System int `json:"system"`
Talkgroup int `json:"talkgroup"`
}
func (q *Queries) GetIncidentTalkgroups(ctx context.Context, incidentID uuid.UUID) ([]GetIncidentTalkgroupsRow, error) {
rows, err := q.db.Query(ctx, getIncidentTalkgroups, incidentID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetIncidentTalkgroupsRow
for rows.Next() {
var i GetIncidentTalkgroupsRow
if err := rows.Scan(&i.System, &i.Talkgroup); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getSystemName = `-- name: GetSystemName :one
SELECT name FROM systems WHERE id = $1
`

View file

@ -11,6 +11,7 @@ import (
"dynatron.me/x/stillbox/pkg/incidents"
"dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities"
"dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/users"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
@ -53,9 +54,12 @@ type Store interface {
// CallIn returns whether an incident is in an call
CallIn(ctx context.Context, inc uuid.UUID, call uuid.UUID) (bool, error)
// TGsIn returns the talkgroups referenced by an incident as a map, primary for rbac use.
TGsIn(ctx context.Context, inc uuid.UUID) (talkgroups.PresenceMap, error)
}
type store struct {
type postgresStore struct {
}
type storeCtxKey string
@ -76,10 +80,10 @@ func FromCtx(ctx context.Context) Store {
}
func NewStore() Store {
return &store{}
return &postgresStore{}
}
func (s *store) CreateIncident(ctx context.Context, inc incidents.Incident) (*incidents.Incident, error) {
func (s *postgresStore) CreateIncident(ctx context.Context, inc incidents.Incident) (*incidents.Incident, error) {
user, err := users.UserCheck(ctx, new(incidents.Incident), "create")
if err != nil {
return nil, err
@ -138,7 +142,7 @@ func (s *store) CreateIncident(ctx context.Context, inc incidents.Incident) (*in
return &inc, nil
}
func (s *store) AddRemoveIncidentCalls(ctx context.Context, incidentID uuid.UUID, addCallIDs []uuid.UUID, notes []byte, removeCallIDs []uuid.UUID) error {
func (s *postgresStore) AddRemoveIncidentCalls(ctx context.Context, incidentID uuid.UUID, addCallIDs []uuid.UUID, notes []byte, removeCallIDs []uuid.UUID) error {
inc, err := s.Owner(ctx, incidentID)
if err != nil {
return err
@ -176,7 +180,7 @@ func (s *store) AddRemoveIncidentCalls(ctx context.Context, incidentID uuid.UUID
}, pgx.TxOptions{})
}
func (s *store) Incidents(ctx context.Context, p IncidentsParams) (incs []Incident, totalCount int, err error) {
func (s *postgresStore) Incidents(ctx context.Context, p IncidentsParams) (incs []Incident, totalCount int, err error) {
_, err = rbac.Check(ctx, new(incidents.Incident), rbac.WithActions(entities.ActionRead))
if err != nil {
return nil, 0, err
@ -281,7 +285,7 @@ func fromDBCalls(d []database.GetIncidentCallsRow) []incidents.IncidentCall {
return r
}
func (s *store) Incident(ctx context.Context, id uuid.UUID) (*incidents.Incident, error) {
func (s *postgresStore) Incident(ctx context.Context, id uuid.UUID) (*incidents.Incident, error) {
_, err := rbac.Check(ctx, &incidents.Incident{ID: id}, rbac.WithActions(entities.ActionRead))
if err != nil {
return nil, err
@ -332,7 +336,7 @@ func (uip UpdateIncidentParams) toDBUIP(id uuid.UUID) database.UpdateIncidentPar
}
}
func (s *store) UpdateIncident(ctx context.Context, id uuid.UUID, p UpdateIncidentParams) (*incidents.Incident, error) {
func (s *postgresStore) UpdateIncident(ctx context.Context, id uuid.UUID, p UpdateIncidentParams) (*incidents.Incident, error) {
ckinc, err := s.Owner(ctx, id)
if err != nil {
return nil, err
@ -355,7 +359,7 @@ func (s *store) UpdateIncident(ctx context.Context, id uuid.UUID, p UpdateIncide
return &inc, nil
}
func (s *store) DeleteIncident(ctx context.Context, id uuid.UUID) error {
func (s *postgresStore) DeleteIncident(ctx context.Context, id uuid.UUID) error {
inc, err := s.Owner(ctx, id)
if err != nil {
return err
@ -369,16 +373,39 @@ func (s *store) DeleteIncident(ctx context.Context, id uuid.UUID) error {
return database.FromCtx(ctx).DeleteIncident(ctx, id)
}
func (s *store) UpdateNotes(ctx context.Context, incidentID uuid.UUID, callID uuid.UUID, notes []byte) error {
func (s *postgresStore) UpdateNotes(ctx context.Context, incidentID uuid.UUID, callID uuid.UUID, notes []byte) error {
return database.FromCtx(ctx).UpdateCallIncidentNotes(ctx, notes, incidentID, callID)
}
func (s *store) Owner(ctx context.Context, id uuid.UUID) (incidents.Incident, error) {
func (s *postgresStore) Owner(ctx context.Context, id uuid.UUID) (incidents.Incident, error) {
owner, err := database.FromCtx(ctx).GetIncidentOwner(ctx, id)
return incidents.Incident{ID: id, Owner: users.UserID(owner)}, err
}
func (s *store) CallIn(ctx context.Context, inc uuid.UUID, call uuid.UUID) (bool, error) {
func (s *postgresStore) CallIn(ctx context.Context, inc uuid.UUID, call uuid.UUID) (bool, error) {
db := database.FromCtx(ctx)
return db.CallInIncident(ctx, inc, call)
}
func (s *postgresStore) TGsIn(ctx context.Context, id uuid.UUID) (talkgroups.PresenceMap, error) {
_, err := rbac.Check(ctx, &incidents.Incident{ID: id}, rbac.WithActions(entities.ActionRead))
if err != nil {
return nil, err
}
db := database.FromCtx(ctx)
tgs, err := db.GetIncidentTalkgroups(ctx, id)
if err != nil {
return nil, err
}
m := make(talkgroups.PresenceMap, len(tgs))
for _, t := range tgs {
m.Put(talkgroups.ID{
System: uint32(t.System),
Talkgroup: uint32(t.Talkgroup),
})
}
return m, nil
}

View file

@ -7,6 +7,7 @@ import (
"reflect"
"dynatron.me/x/stillbox/pkg/incidents/incstore"
"dynatron.me/x/stillbox/pkg/talkgroups"
"github.com/el-mike/restrict/v2"
"github.com/google/uuid"
@ -16,8 +17,58 @@ const (
SubmitterEqualConditionType = "SUBMITTER_EQUAL"
InMapConditionType = "IN_MAP"
CallInIncidentConditionType = "CALL_IN_INCIDENT"
TGInIncidentConditionType = "TG_IN_INCIDENT"
)
type TGInIncidentCondition struct {
ID string `json:"name,omitempty" yaml:"name,omitempty"`
TG *restrict.ValueDescriptor `json:"tg" yaml:"tg"`
Incident *restrict.ValueDescriptor `json:"incident" yaml:"incident"`
}
func (*TGInIncidentCondition) Type() string {
return TGInIncidentConditionType
}
func (c *TGInIncidentCondition) Check(r *restrict.AccessRequest) error {
tgVID, err := c.TG.GetValue(r)
if err != nil {
return err
}
incVID, err := c.Incident.GetValue(r)
if err != nil {
return err
}
ctx, hasCtx := r.Context["ctx"].(context.Context)
if !hasCtx {
return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf("no context provided"))
}
incID, isUUID := incVID.(uuid.UUID)
if !isUUID {
return restrict.NewConditionNotSatisfiedError(c, r, errors.New("incident ID is not UUID"))
}
tgID, isTGID := tgVID.(talkgroups.ID)
if !isTGID {
return restrict.NewConditionNotSatisfiedError(c, r, errors.New("tg ID is not TGID"))
}
// XXX: this should instead come from the access request context, for better reuse upstream
tgm, err := incstore.FromCtx(ctx).TGsIn(ctx, incID)
if err != nil {
return restrict.NewConditionNotSatisfiedError(c, r, err)
}
if !tgm.Has(tgID) {
return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf(`tg "%v" not in incident "%v"`, tgID, incID))
}
return nil
}
type CallInIncidentCondition struct {
ID string `json:"name,omitempty" yaml:"name,omitempty"`
Call *restrict.ValueDescriptor `json:"call" yaml:"call"`
@ -60,7 +111,7 @@ func (c *CallInIncidentCondition) Check(r *restrict.AccessRequest) error {
}
if !inCall {
return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf(`incident "%v" not in call "%v"`, incID, callID))
return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf(`call "%v" not in incident "%v"`, callID, incID))
}
return nil

View file

@ -54,6 +54,7 @@ func New(baseURL url.URL) *api {
ShareRequestCallDL: s.calls.shareCallDLRoute,
ShareRequestIncident: s.incidents.getIncident,
ShareRequestIncidentM3U: s.incidents.getCallsM3U,
ShareRequestTalkgroups: s.tgs.getTGsShareRoute,
},
)

View file

@ -102,17 +102,17 @@ func (ca *callsAPI) getAudio(p getAudioParams, w http.ResponseWriter, r *http.Re
_, _ = w.Write(call.AudioBlob)
}
func (ca *callsAPI) shareCallRoute(id uuid.UUID, _ *shares.Share, w http.ResponseWriter, r *http.Request) {
func (ca *callsAPI) shareCallRoute(id ID, _ *shares.Share, w http.ResponseWriter, r *http.Request) {
p := getAudioParams{
CallID: &id,
CallID: common.PtrTo(id.(uuid.UUID)),
}
ca.getAudio(p, w, r)
}
func (ca *callsAPI) shareCallDLRoute(id uuid.UUID, _ *shares.Share, w http.ResponseWriter, r *http.Request) {
func (ca *callsAPI) shareCallDLRoute(id ID, _ *shares.Share, w http.ResponseWriter, r *http.Request) {
p := getAudioParams{
CallID: &id,
CallID: common.PtrTo(id.(uuid.UUID)),
Download: common.PtrTo("download"),
}

View file

@ -97,10 +97,10 @@ func (ia *incidentsAPI) getIncidentRoute(w http.ResponseWriter, r *http.Request)
ia.getIncident(id, nil, w, r)
}
func (ia *incidentsAPI) getIncident(id uuid.UUID, share *shares.Share, w http.ResponseWriter, r *http.Request) {
func (ia *incidentsAPI) getIncident(id ID, share *shares.Share, w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
incs := incstore.FromCtx(ctx)
inc, err := incs.Incident(ctx, id)
inc, err := incs.Incident(ctx, id.(uuid.UUID))
if err != nil {
wErr(w, r, autoError(err))
return
@ -198,12 +198,12 @@ func (ia *incidentsAPI) getCallsM3URoute(w http.ResponseWriter, r *http.Request)
ia.getCallsM3U(id, nil, w, r)
}
func (ia *incidentsAPI) getCallsM3U(id uuid.UUID, share *shares.Share, w http.ResponseWriter, r *http.Request) {
func (ia *incidentsAPI) getCallsM3U(id ID, share *shares.Share, w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
incs := incstore.FromCtx(ctx)
tgst := tgstore.FromCtx(ctx)
inc, err := incs.Incident(ctx, id)
inc, err := incs.Incident(ctx, id.(uuid.UUID))
if err != nil {
wErr(w, r, autoError(err))
return
@ -214,7 +214,7 @@ func (ia *incidentsAPI) getCallsM3U(id uuid.UUID, share *shares.Share, w http.Re
callUrl := common.PtrTo(*ia.baseURL)
urlRoot := "/api/call"
if share != nil {
urlRoot = fmt.Sprintf("/share/%s/%s/call/", share.Type, share.ID)
urlRoot = fmt.Sprintf("/share/%s/call/", share.ID)
}
b.WriteString("#EXTM3U\n\n")

View file

@ -25,11 +25,13 @@ const (
ShareRequestCallDL ShareRequestType = "callDL"
ShareRequestIncident ShareRequestType = "incident"
ShareRequestIncidentM3U ShareRequestType = "m3u"
ShareRequestTalkgroups ShareRequestType = "talkgroups"
)
func (rt ShareRequestType) IsValid() bool {
switch rt {
case ShareRequestCall, ShareRequestCallDL, ShareRequestIncident, ShareRequestIncidentM3U:
case ShareRequestCall, ShareRequestCallDL, ShareRequestIncident,
ShareRequestIncidentM3U, ShareRequestTalkgroups:
return true
}
@ -38,14 +40,17 @@ func (rt ShareRequestType) IsValid() bool {
func (rt ShareRequestType) IsValidSubtype() bool {
switch rt {
case ShareRequestCall, ShareRequestCallDL:
case ShareRequestCall, ShareRequestCallDL, ShareRequestTalkgroups:
return true
}
return false
}
type HandlerFunc func(id uuid.UUID, share *shares.Share, w http.ResponseWriter, r *http.Request)
type ID interface {
}
type HandlerFunc func(id ID, share *shares.Share, w http.ResponseWriter, r *http.Request)
type ShareHandlers map[ShareRequestType]HandlerFunc
type shareAPI struct {
baseURL *url.URL
@ -71,8 +76,8 @@ func (sa *shareAPI) Subrouter() http.Handler {
func (sa *shareAPI) RootRouter() http.Handler {
r := chi.NewMux()
r.Get("/{type}/{shareId:[A-Za-z0-9_-]{20,}}", sa.routeShare)
r.Get("/{type}/{shareId:[A-Za-z0-9_-]{20,}}/{subType}/{subID}", sa.routeShare)
r.Get("/{shareId:[A-Za-z0-9_-]{20,}}/{type}", sa.routeShare)
r.Get("/{shareId:[A-Za-z0-9_-]{20,}}/{type}/{subID}", sa.routeShare)
return r
}
@ -102,10 +107,9 @@ func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) {
shs := shares.FromCtx(ctx)
params := struct {
Type string `param:"type"`
ID string `param:"shareId"`
SubType *string `param:"subType"`
SubID *string `param:"subID"`
Type string `param:"type"`
ID string `param:"shareId"`
SubID *string `param:"subID"`
}{}
err := decodeParams(&params, r)
@ -136,15 +140,11 @@ func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) {
ctx = entities.CtxWithSubject(ctx, sh)
r = r.WithContext(ctx)
if params.SubType != nil {
switch rType {
case ShareRequestTalkgroups:
sa.shnd[rType](nil, sh, w, r)
case ShareRequestCall, ShareRequestCallDL:
if params.SubID == nil {
// probably can't happen
wErr(w, r, autoError(ErrBadShare))
return
}
subT := ShareRequestType(*params.SubType)
if !subT.IsValidSubtype() {
wErr(w, r, autoError(ErrBadShare))
return
}
@ -154,12 +154,10 @@ func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) {
wErr(w, r, badRequest(err))
return
}
sa.shnd[subT](subIDU, sh, w, r)
return
sa.shnd[rType](subIDU, sh, w, r)
case ShareRequestIncident, ShareRequestIncidentM3U:
sa.shnd[rType](sh.EntityID, sh, w, r)
}
sa.shnd[rType](sh.EntityID, sh, w, r)
}
func (sa *shareAPI) deleteShare(w http.ResponseWriter, r *http.Request) {

View file

@ -7,6 +7,8 @@ import (
"dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/incidents/incstore"
"dynatron.me/x/stillbox/pkg/shares"
"dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"dynatron.me/x/stillbox/pkg/talkgroups/xport"
@ -159,6 +161,30 @@ func (tga *talkgroupAPI) postPaginated(w http.ResponseWriter, r *http.Request) {
respond(w, r, res)
}
func (tga *talkgroupAPI) getTGsShareRoute(_ ID, share *shares.Share, w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
tgs := tgstore.FromCtx(ctx)
tgIDs, err := incstore.FromCtx(ctx).TGsIn(ctx, share.EntityID)
if err != nil {
wErr(w, r, autoError(err))
return
}
idSl := make(talkgroups.IDs, 0, len(tgIDs))
for id := range tgIDs {
idSl = append(idSl, id)
}
tgRes, err := tgs.TGs(ctx, idSl)
if err != nil {
wErr(w, r, autoError(err))
return
}
respond(w, r, tgRes)
}
func (tga *talkgroupAPI) put(w http.ResponseWriter, r *http.Request) {
var id tgParams
err := decodeParams(&id, r)

View file

@ -41,6 +41,18 @@ type ID struct {
Talkgroup uint32 `json:"tg"`
}
type PresenceMap map[ID]struct{}
func (t PresenceMap) Has(id ID) bool {
_, has := t[id]
return has
}
func (t PresenceMap) Put(id ID) {
t[id] = struct{}{}
}
var _ encoding.TextUnmarshaler = (*ID)(nil)
var ErrBadTG = errors.New("bad talkgroup format")

View file

@ -24,11 +24,3 @@ DELETE FROM shares WHERE id = @id;
-- name: PruneShares :exec
DELETE FROM shares WHERE expiration < NOW();
-- name: GetIncidentTalkgroups :many
SELECT DISTINCT
c.system,
c.talkgroup
FROM incidents_calls ic
JOIN calls c ON (c.id = ic.call_id AND c.call_date = ic.call_date)
WHERE ic.incident_id = @incident_id;

View file

@ -281,3 +281,11 @@ INSERT INTO systems(id, name) VALUES(@id, @name);
-- name: DeleteSystem :exec
DELETE FROM systems WHERE id = @id;
-- name: GetIncidentTalkgroups :many
SELECT DISTINCT
c.system,
c.talkgroup
FROM incidents_calls ic
JOIN calls c ON (c.id = ic.call_id AND c.call_date = ic.call_date)
WHERE ic.incident_id = @incident_id;