Merge pull request 'Shares' (#109) from shareUI into trunk

Reviewed-on: #109
This commit is contained in:
Daniel Ponte 2025-02-14 00:25:02 -05:00
commit 83241684e0
82 changed files with 1875 additions and 510 deletions

View file

@ -1,4 +1,4 @@
Copyright (c) 2024 Daniel Ponte. Copyright (c) 2024, 2025 Daniel Ponte.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

View file

@ -64,7 +64,7 @@ protobuf best practices (i.e. not changing field numbers).
## License and Copyright ## License and Copyright
© 2024, Daniel Ponte <dan AT dynatron DOT me> © 2024, 2025 Daniel Ponte <dan AT dynatron DOT me>
Licensed under the 3-clause BSD license. See LICENSE for details. Licensed under the 3-clause BSD license. See LICENSE for details.

View file

@ -26,5 +26,16 @@
] ]
} }
} }
],
"navigationUrls": [
"/**",
"!/**/*.*",
"!/**/****",
"!/**/****/**",
"!/tgstats",
"!/tgstats/**",
"!/api/**",
"!/share/**",
"!/testnotify"
] ]
} }

View file

@ -8,7 +8,7 @@
</div> </div>
<div class="centerNav"></div> <div class="centerNav"></div>
<div class="rightNav"> <div class="rightNav">
@if (auth.loggedIn) { @if (auth.isAuth()) {
<button class="ybtn sbButton"> <button class="ybtn sbButton">
<a (click)="logout()" class="logout">Logout</a> <a (click)="logout()" class="logout">Logout</a>
</button> </button>
@ -19,7 +19,7 @@
</header> </header>
<div class="container"> <div class="container">
@if (auth.loggedIn) { @if (auth.isAuth()) {
<app-navigation [events]="toggleNavSubject.asObservable()"></app-navigation> <app-navigation [events]="toggleNavSubject.asObservable()"></app-navigation>
} @else { } @else {
<div class="content"> <div class="content">

View file

@ -29,7 +29,7 @@ export function authIntercept(
next: HttpHandlerFn, next: HttpHandlerFn,
): Observable<HttpEvent<unknown>> { ): Observable<HttpEvent<unknown>> {
let authSvc: AuthService = inject(AuthService); let authSvc: AuthService = inject(AuthService);
if (authSvc.loggedIn) { if (authSvc.isAuth()) {
req = req.clone({ req = req.clone({
setHeaders: { setHeaders: {
Authorization: `Bearer ${authSvc.getToken()}`, Authorization: `Bearer ${authSvc.getToken()}`,

View file

@ -8,6 +8,11 @@ export const routes: Routes = [
loadComponent: () => loadComponent: () =>
import('./login/login.component').then((m) => m.LoginComponent), import('./login/login.component').then((m) => m.LoginComponent),
}, },
{
path: 's/:id',
loadComponent: () =>
import('./share/share.component').then((m) => m.ShareComponent),
},
{ {
path: '', path: '',
canActivateChild: [AuthGuard], canActivateChild: [AuthGuard],
@ -71,6 +76,12 @@ export const routes: Routes = [
import('./alerts/alerts.component').then((m) => m.AlertsComponent), import('./alerts/alerts.component').then((m) => m.AlertsComponent),
data: { title: 'Alerts' }, data: { title: 'Alerts' },
}, },
{
path: 'shares',
loadComponent: () =>
import('./shares/shares.component').then((m) => m.SharesComponent),
data: { title: 'Shares' },
},
], ],
}, },
]; ];

View file

@ -5,7 +5,7 @@ import { inject } from '@angular/core';
export const AuthGuard: CanActivateFn = (route, state) => { export const AuthGuard: CanActivateFn = (route, state) => {
const router: Router = inject(Router); const router: Router = inject(Router);
const authSvc: AuthService = inject(AuthService); const authSvc: AuthService = inject(AuthService);
if (localStorage.getItem('jwt') == null) { if (authSvc.token() === null) {
let success = false; let success = false;
authSvc.refresh().subscribe({ authSvc.refresh().subscribe({
next: (event) => { next: (event) => {

View file

@ -1,8 +1,9 @@
export interface CallRecord { export interface CallRecord {
id: string; id: string;
call_date: Date; callDate: Date;
audioURL: string | null;
duration: number; duration: number;
system_id: number; systemId: number;
tgid: number; tgid: number;
incidents: number; // in incident incidents: number; // in incident
} }

View file

@ -0,0 +1,15 @@
<mat-card class="callInfo" appearance="outlined">
<div class="cardHdr">
<h1>
{{ call | talkgroup: "alpha" | async }} &#64;
{{ call.callDate | grabDate }} {{ call.callDate | time: true }}
</h1>
</div>
<div class="call-heading">
<div class="field field-label">Talkgroup</div>
<div class="field field-data">{{ call | talkgroup: "name" | async }}</div>
<div class="field field-label">Group</div>
<div class="field field-data">{{ call | talkgroup: "group" | async }}</div>
</div>
<audio controls [src]="call.audioURL! | safe: 'resourceUrl'"></audio>
</mat-card>

View file

@ -0,0 +1,63 @@
.callInfo {
margin: 0px 50px 50px 50px;
padding: 50px 50px 50px 50px;
display: flex;
flex-flow: column;
margin-left: auto;
margin-right: auto;
}
.call-heading {
display: flex;
margin-bottom: 20px;
flex: 0 0;
flex-wrap: wrap;
}
.cardHdr {
display: flex;
flex-flow: row wrap;
margin-bottom: 24px;
}
.field {
flex: 1 0;
padding-left: 10px;
padding-right: 10px;
}
.field-label {
font-weight: bolder;
flex-grow: 0;
padding-right: 5px;
}
.field-label::after {
content: ":";
}
.cardHdr h1 {
flex: 1 1;
margin: 0;
}
.cardHdr a {
flex: 0 0;
justify-content: flex-end;
align-content: center;
cursor: pointer;
}
form mat-form-field {
width: 60rem;
flex: 0 0;
display: flex;
}
.callRecord {
display: flex;
flex-flow: column nowrap;
justify-content: center;
margin-top: 20px;
margin-left: auto;
margin-right: auto;
}

View file

@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CallInfoComponent } from './call-info.component';
describe('CallInfoComponent', () => {
let component: CallInfoComponent;
let fixture: ComponentFixture<CallInfoComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CallInfoComponent],
}).compileComponents();
fixture = TestBed.createComponent(CallInfoComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,28 @@
import { Component, Input } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { CallRecord } from '../../calls';
import { Share } from '../../shares';
import { DatePipe, SafePipe, TalkgroupPipe, TimePipe } from '../calls.service';
import { AsyncPipe } from '@angular/common';
@Component({
selector: 'app-call-info',
imports: [
MatCardModule,
TimePipe,
DatePipe,
TalkgroupPipe,
AsyncPipe,
SafePipe,
],
templateUrl: './call-info.component.html',
styleUrl: './call-info.component.scss',
})
export class CallInfoComponent {
@Input() share!: Share;
call!: CallRecord;
ngOnInit() {
this.call = this.share.sharedItem as CallRecord;
}
}

View file

@ -135,13 +135,13 @@
<ng-container matColumnDef="date"> <ng-container matColumnDef="date">
<th mat-header-cell *matHeaderCellDef>Date</th> <th mat-header-cell *matHeaderCellDef>Date</th>
<td mat-cell *matCellDef="let call"> <td mat-cell *matCellDef="let call">
{{ call.call_date | grabDate }} {{ call.callDate | grabDate }}
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="time"> <ng-container matColumnDef="time">
<th mat-header-cell *matHeaderCellDef>Time</th> <th mat-header-cell *matHeaderCellDef>Time</th>
<td [title]="call.call_date" mat-cell *matCellDef="let call"> <td [title]="call.callDate" mat-cell *matCellDef="let call">
{{ call.call_date | time }} {{ call.callDate | time }}
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="system"> <ng-container matColumnDef="system">

View file

@ -1,10 +1,4 @@
import { import { Component, inject, ViewChild } from '@angular/core';
Component,
inject,
Pipe,
PipeTransform,
ViewChild,
} from '@angular/core';
import { CommonModule, AsyncPipe } from '@angular/common'; import { CommonModule, AsyncPipe } from '@angular/common';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
@ -17,13 +11,20 @@ import { PrefsService } from '../prefs/prefs.service';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { SelectionModel } from '@angular/cdk/collections'; import { SelectionModel } from '@angular/cdk/collections';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
import { BehaviorSubject, Observable, Subscription } from 'rxjs'; import { BehaviorSubject, Subject, Subscription } from 'rxjs';
import { map, switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
import { CallsListParams, CallsService } from './calls.service'; import {
CallsListParams,
CallsService,
DatePipe,
DownloadURLPipe,
FixedPointPipe,
TalkgroupPipe,
TimePipe,
} from './calls.service';
import { CallRecord } from '../calls'; import { CallRecord } from '../calls';
import { TalkgroupService } from '../talkgroups/talkgroups.service'; import { TalkgroupService } from '../talkgroups/talkgroups.service';
import { Talkgroup } from '../talkgroup';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { import {
FormControl, FormControl,
@ -49,94 +50,6 @@ import {
import { IncidentRecord } from '../incidents'; import { IncidentRecord } from '../incidents';
import { SelectIncidentDialogComponent } from '../incidents/select-incident-dialog/select-incident-dialog.component'; import { SelectIncidentDialogComponent } from '../incidents/select-incident-dialog/select-incident-dialog.component';
@Pipe({
name: 'grabDate',
standalone: true,
pure: true,
})
export class DatePipe implements PipeTransform {
transform(ts: string, args?: any): string {
const timestamp = new Date(ts);
return timestamp.getMonth() + 1 + '/' + timestamp.getDate();
}
}
@Pipe({
name: 'time',
standalone: true,
pure: true,
})
export class TimePipe implements PipeTransform {
transform(ts: string, args?: any): string {
const timestamp = new Date(ts);
return timestamp.toLocaleTimeString(navigator.language, {
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
});
}
}
@Pipe({
name: 'talkgroup',
standalone: true,
pure: true,
})
export class TalkgroupPipe implements PipeTransform {
constructor(private tgService: TalkgroupService) {}
transform(call: CallRecord, field: string): Observable<string> {
return this.tgService.getTalkgroup(call.system_id, call.tgid).pipe(
map((tg: Talkgroup) => {
switch (field) {
case 'alpha': {
return tg.alpha_tag ?? call.tgid;
break;
}
case 'group': {
return tg.tg_group ?? '\u2014';
break;
}
case 'system': {
return tg.system?.name ?? tg.system_id.toString();
}
default: {
return tg.name ?? '\u2014';
break;
}
}
}),
);
}
}
@Pipe({
name: 'fixedPoint',
standalone: true,
pure: true,
})
export class FixedPointPipe implements PipeTransform {
constructor() {}
transform(quant: number, divisor: number, places: number): string {
const seconds = quant / divisor;
return seconds.toFixed(places);
}
}
@Pipe({
name: 'audioDownloadURL',
standalone: true,
pure: true,
})
export class DownloadURLPipe implements PipeTransform {
constructor(private callsSvc: CallsService) {}
transform(call: CallRecord, args?: any): string {
return this.callsSvc.callAudioDownloadURL(call.id);
}
}
const reqPageSize = 200; const reqPageSize = 200;
@Component({ @Component({
selector: 'app-calls', selector: 'app-calls',
@ -144,8 +57,8 @@ const reqPageSize = 200;
MatIconModule, MatIconModule,
FixedPointPipe, FixedPointPipe,
TalkgroupPipe, TalkgroupPipe,
DatePipe,
TimePipe, TimePipe,
DatePipe,
MatPaginatorModule, MatPaginatorModule,
MatTableModule, MatTableModule,
AsyncPipe, AsyncPipe,
@ -329,6 +242,7 @@ export class CallsComponent {
this.subscriptions.add( this.subscriptions.add(
this.fetchCalls this.fetchCalls
.pipe( .pipe(
debounceTime(500),
switchMap((params) => { switchMap((params) => {
return this.callsSvc.getCalls(params); return this.callsSvc.getCalls(params);
}), }),

View file

@ -1,8 +1,155 @@
import { Injectable } from '@angular/core'; import { Injectable, Pipe, PipeTransform } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; import { map, Observable } from 'rxjs';
import { CallRecord } from '../calls'; import { CallRecord } from '../calls';
import { environment } from '.././../environments/environment'; import { environment } from '.././../environments/environment';
import { TalkgroupService } from '../talkgroups/talkgroups.service';
import { Talkgroup } from '../talkgroup';
import { Share } from '../shares';
import {
DomSanitizer,
SafeHtml,
SafeResourceUrl,
SafeScript,
SafeStyle,
SafeUrl,
} from '@angular/platform-browser';
@Pipe({
name: 'grabDate',
standalone: true,
pure: true,
})
export class DatePipe implements PipeTransform {
transform(ts: string | Date, args?: any): string {
const timestamp = new Date(ts);
return timestamp.getMonth() + 1 + '/' + timestamp.getDate();
}
}
@Pipe({
name: 'time',
standalone: true,
pure: true,
})
export class TimePipe implements PipeTransform {
transform(ts: string | Date, haveSecond: boolean = false): string {
const timestamp = new Date(ts);
return timestamp.toLocaleTimeString(navigator.language, {
hour: '2-digit',
minute: '2-digit',
second: haveSecond ? '2-digit' : undefined,
hourCycle: 'h23',
});
}
}
@Pipe({
name: 'talkgroup',
standalone: true,
pure: true,
})
export class TalkgroupPipe implements PipeTransform {
constructor(private tgService: TalkgroupService) {}
transform(
call: CallRecord,
field: string,
share: Share | null = null,
): Observable<string> {
return this.tgService.getTalkgroup(call.systemId, call.tgid).pipe(
map((tg: Talkgroup) => {
switch (field) {
case 'alpha': {
return tg.alphaTag ?? call.tgid;
break;
}
case 'group': {
return tg.tgGroup ?? '\u2014';
break;
}
case 'system': {
return tg.system?.name ?? tg.systemId.toString();
}
default: {
return tg.name ?? '\u2014';
break;
}
}
}),
);
}
}
@Pipe({
name: 'fixedPoint',
standalone: true,
pure: true,
})
export class FixedPointPipe implements PipeTransform {
constructor() {}
transform(quant: number, divisor: number, places: number): string {
const seconds = quant / divisor;
return seconds.toFixed(places);
}
}
/**
* Sanitize HTML
*/
@Pipe({
name: 'safe',
})
export class SafePipe implements PipeTransform {
/**
* Pipe Constructor
*
* @param _sanitizer: DomSanitezer
*/
// tslint:disable-next-line
constructor(protected _sanitizer: DomSanitizer) {}
/**
* Transform
*
* @param value: string
* @param type: string
*/
transform(
value: string,
type: string,
): SafeHtml | SafeStyle | SafeScript | SafeUrl | SafeResourceUrl {
switch (type) {
case 'html':
return this._sanitizer.bypassSecurityTrustHtml(value);
case 'style':
return this._sanitizer.bypassSecurityTrustStyle(value);
case 'script':
return this._sanitizer.bypassSecurityTrustScript(value);
case 'url':
return this._sanitizer.bypassSecurityTrustUrl(value);
case 'resourceUrl':
let res = this._sanitizer.bypassSecurityTrustResourceUrl(value);
return res;
default:
return this._sanitizer.bypassSecurityTrustHtml(value);
}
}
}
@Pipe({
name: 'audioDownloadURL',
standalone: true,
pure: true,
})
export class DownloadURLPipe implements PipeTransform {
constructor(private callsSvc: CallsService) {}
transform(call: CallRecord, args?: any): string {
return this.callsSvc.callAudioDownloadURL(call.id);
}
}
export interface CallsListParams { export interface CallsListParams {
start: Date | null; start: Date | null;

View file

@ -30,7 +30,11 @@ export class CallPlayerComponent {
this.playSub.unsubscribe(); this.playSub.unsubscribe();
}); });
this.playing = true; this.playing = true;
this.au.src = this.callsSvc.callAudioURL(this.call.id); if (this.call.audioURL != null) {
this.au.src = this.call.audioURL;
} else {
this.au.src = this.callsSvc.callAudioURL(this.call.id);
}
this.au.load(); this.au.load();
this.au.play().then(null, (reason) => { this.au.play().then(null, (reason) => {
this.playing = false; this.playing = false;

View file

@ -3,21 +3,32 @@
<div class="cardHdr"> <div class="cardHdr">
<h1> <h1>
{{ inc?.name }} {{ inc?.name }}
<a [href]="'/api/incident/' + incID + '.m3u'" <a
[href]="
share
? '/share/' + share.id + '/m3u'
: '/api/incident/' + incID + '.m3u'
"
><mat-icon>playlist_play</mat-icon></a ><mat-icon>playlist_play</mat-icon></a
> >
</h1> </h1>
<button mat-icon-button (click)="editIncident(incID)"> @if (share == null) {
<mat-icon>edit</mat-icon> <button mat-icon-button (click)="editIncident(incID)">
</button> <mat-icon>edit</mat-icon>
<button mat-icon-button [matMenuTriggerFor]="moreMenu">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #moreMenu="matMenu">
<button class="deleteItem" mat-menu-item (click)="deleteIncident(incID)">
Delete
</button> </button>
</mat-menu> <button mat-icon-button [matMenuTriggerFor]="moreMenu">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #moreMenu="matMenu">
<button
class="deleteItem"
mat-menu-item
(click)="deleteIncident(incID)"
>
Delete
</button>
</mat-menu>
}
</div> </div>
<div class="inc-heading"> <div class="inc-heading">
<div class="field field-start field-label">Start</div> <div class="field field-start field-label">Start</div>
@ -64,25 +75,25 @@
<ng-container matColumnDef="date"> <ng-container matColumnDef="date">
<th mat-header-cell *matHeaderCellDef>Date</th> <th mat-header-cell *matHeaderCellDef>Date</th>
<td mat-cell *matCellDef="let call"> <td mat-cell *matCellDef="let call">
{{ call.call_date | grabDate }} {{ call.callDate | grabDate }}
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="time"> <ng-container matColumnDef="time">
<th mat-header-cell *matHeaderCellDef>Time</th> <th mat-header-cell *matHeaderCellDef>Time</th>
<td [title]="call.call_date" mat-cell *matCellDef="let call"> <td [title]="call.callDate" mat-cell *matCellDef="let call">
{{ call.call_date | time }} {{ call.callDate | time }}
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="system"> <ng-container matColumnDef="system">
<th mat-header-cell *matHeaderCellDef>System</th> <th mat-header-cell *matHeaderCellDef>System</th>
<td mat-cell *matCellDef="let call"> <td mat-cell *matCellDef="let call">
{{ call | talkgroup: "system" | async }} {{ call | talkgroup: "system" : share | async }}
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="group"> <ng-container matColumnDef="group">
<th mat-header-cell *matHeaderCellDef>Group</th> <th mat-header-cell *matHeaderCellDef>Group</th>
<td mat-cell *matCellDef="let call"> <td mat-cell *matCellDef="let call">
{{ call | talkgroup: "group" | async }} {{ call | talkgroup: "group" : share | async }}
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="talkgroup"> <ng-container matColumnDef="talkgroup">

View file

@ -1,5 +1,5 @@
.incident { .incident {
margin: 50px 50px 50px 50px; margin: 0px 50px 50px 50px;
padding: 50px 50px 50px 50px; padding: 50px 50px 50px 50px;
display: flex; display: flex;
flex-flow: column; flex-flow: column;
@ -7,12 +7,6 @@
margin-right: auto; margin-right: auto;
} }
@media not screen and (max-width: 768px) {
.incident {
width: 75%;
}
}
.inc-heading { .inc-heading {
display: flex; display: flex;
margin-bottom: 20px; margin-bottom: 20px;

View file

@ -1,7 +1,7 @@
import { Component, inject } from '@angular/core'; import { Component, inject, Input } from '@angular/core';
import { tap } from 'rxjs/operators'; import { tap } from 'rxjs/operators';
import { CommonModule, Location } from '@angular/common'; import { CommonModule, Location } from '@angular/common';
import { BehaviorSubject, merge, Subscription } from 'rxjs'; import { BehaviorSubject, merge, Subject, Subscription } from 'rxjs';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { import {
ReactiveFormsModule, ReactiveFormsModule,
@ -35,10 +35,13 @@ import {
TimePipe, TimePipe,
DatePipe, DatePipe,
DownloadURLPipe, DownloadURLPipe,
} from '../../calls/calls.component'; } from '../../calls/calls.service';
import { CallPlayerComponent } from '../../calls/player/call-player/call-player.component'; import { CallPlayerComponent } from '../../calls/player/call-player/call-player.component';
import { FmtDatePipe } from '../incidents.component'; import { FmtDatePipe } from '../incidents.component';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { Share } from '../../shares';
import { ShareService } from '../../share/share.service';
import { TalkgroupService } from '../../talkgroups/talkgroups.service';
export interface EditDialogData { export interface EditDialogData {
incID: string; incID: string;
@ -151,8 +154,9 @@ export class IncidentEditDialogComponent {
styleUrl: './incident.component.scss', styleUrl: './incident.component.scss',
}) })
export class IncidentComponent { export class IncidentComponent {
incPrime = new BehaviorSubject<IncidentRecord>(<IncidentRecord>{}); incPrime = new Subject<IncidentRecord>();
inc$!: Observable<IncidentRecord>; inc$!: Observable<IncidentRecord>;
@Input() share?: Share;
subscriptions: Subscription = new Subscription(); subscriptions: Subscription = new Subscription();
dialog = inject(MatDialog); dialog = inject(MatDialog);
incID!: string; incID!: string;
@ -174,15 +178,30 @@ export class IncidentComponent {
private route: ActivatedRoute, private route: ActivatedRoute,
private incSvc: IncidentsService, private incSvc: IncidentsService,
private location: Location, private location: Location,
private tgSvc: TalkgroupService,
) {} ) {}
saveIncName(ev: Event) {} saveIncName(ev: Event) {}
ngOnInit() { ngOnInit() {
this.incID = this.route.snapshot.paramMap.get('id')!; if (this.share) {
this.inc$ = merge(this.incSvc.getIncident(this.incID), this.incPrime).pipe( this.tgSvc.setShare(this.share);
}
let incOb: Observable<IncidentRecord>;
if (this.route.component === this.constructor) {
// loaded by route
this.incID = this.route.snapshot.paramMap.get('id')!;
incOb = this.incSvc.getIncident(this.incID);
} else {
if (!this.share) {
return;
}
this.incID = (this.share.sharedItem as IncidentRecord).id;
incOb = new BehaviorSubject(this.share.sharedItem as IncidentRecord);
}
this.inc$ = merge(incOb, this.incPrime).pipe(
tap((inc) => { tap((inc) => {
if (inc.calls) { if (inc && inc.calls) {
this.callsResult.data = inc.calls; this.callsResult.data = inc.calls;
} }
}), }),

View file

@ -1,56 +1,95 @@
import { Injectable } from '@angular/core'; import {
Injectable,
signal,
computed,
effect,
inject,
DestroyRef,
} from '@angular/core';
import { HttpClient, HttpResponse } from '@angular/common/http'; import { HttpClient, HttpResponse } from '@angular/common/http';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Observable } from 'rxjs'; import { Observable, Subject } from 'rxjs';
import { tap } from 'rxjs/operators'; import { tap } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export class Jwt { export class Jwt {
constructor(public jwt: string) {} constructor(public jwt: string) {}
} }
type AuthState = {
user: string | null;
token: string | null;
is_auth: boolean;
};
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class AuthService { export class AuthService {
loggedIn: boolean = false; private _accessTokenKey = 'jwt';
private _storedToken = localStorage.getItem(this._accessTokenKey);
destroyed = inject(DestroyRef);
private _state = signal<AuthState>({
user: null,
token: this._storedToken,
is_auth: this._storedToken !== null,
});
loginFailed = signal<boolean>(false);
token = computed(() => this._state().token);
isAuth = computed(() => this._state().is_auth);
user = computed(() => this._state().user);
constructor( constructor(
private http: HttpClient, private http: HttpClient,
private _router: Router, private _router: Router,
) { ) {
let ssJWT = localStorage.getItem('jwt'); effect(() => {
if (ssJWT) { const token = this.token();
this.loggedIn = true; if (token !== null) {
} localStorage.setItem(this._accessTokenKey, token);
} else {
localStorage.removeItem(this._accessTokenKey);
}
});
} }
login(username: string, password: string): Observable<HttpResponse<Jwt>> { login(username: string, password: string) {
return this.http return this.http
.post<Jwt>( .post<Jwt>('/api/login', { username: username, password: password })
'/api/login', .pipe(takeUntilDestroyed(this.destroyed))
{ username: username, password: password }, .subscribe({
{ observe: 'response' }, next: (res) => {
) let state = <AuthState>{
.pipe( user: username,
tap((event) => { token: res.jwt,
if (event.status == 200) { is_auth: true,
localStorage.setItem('jwt', event.body?.jwt.toString() ?? ''); };
this.loggedIn = true; this._state.set(state);
this._router.navigateByUrl('/home'); this.loginFailed.update(() => false);
} this._router.navigateByUrl('/');
}), },
); error: (err) => {
this.loginFailed.update(() => true);
},
});
}
_clearState() {
this._state.set(<AuthState>{
is_auth: false,
token: null,
});
} }
logout() { logout() {
this.http this.http.get('/api/logout', { withCredentials: true }).subscribe({
.get('/api/logout', { withCredentials: true, observe: 'response' }) next: (event) => {
.subscribe((event) => { this._clearState();
if (event.status == 200) { },
this.loggedIn = false; error: (err) => {
} this._clearState();
}); },
localStorage.removeItem('jwt'); });
this.loggedIn = false;
this._router.navigateByUrl('/login'); this._router.navigateByUrl('/login');
} }
@ -60,14 +99,20 @@ export class AuthService {
.pipe( .pipe(
tap((event) => { tap((event) => {
if (event.status == 200) { if (event.status == 200) {
localStorage.setItem('jwt', event.body?.jwt.toString() ?? ''); let ost = this._state();
this.loggedIn = true; let tok = event.body?.jwt.toString();
let state = <AuthState>{
user: ost.user,
token: tok ? tok : null,
is_auth: true,
};
this._state.set(state);
} }
}), }),
); );
} }
getToken(): string | null { getToken(): string | null {
return localStorage.getItem('jwt'); return localStorage.getItem(this._accessTokenKey);
} }
} }

View file

@ -28,7 +28,7 @@
<button class="login sbButton" (click)="onSubmit()">Login</button> <button class="login sbButton" (click)="onSubmit()">Login</button>
</div> </div>
</form> </form>
@if (failed) { @if (failed()) {
<div role="alert"> <div role="alert">
<span>Login Failed!</span> <span>Login Failed!</span>
</div> </div>

View file

@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { AuthService } from '../login/auth.service'; import { AuthService } from '../login/auth.service';
import { catchError, of, Subscription } from 'rxjs'; import { catchError, of, Subscription } from 'rxjs';
@ -17,31 +17,9 @@ export class LoginComponent {
router: Router = inject(Router); router: Router = inject(Router);
username: string = ''; username: string = '';
password: string = ''; password: string = '';
failed: boolean = false; failed = this.apiService.loginFailed;
private subscriptions = new Subscription();
onSubmit() { onSubmit() {
this.failed = false; this.apiService.login(this.username, this.password);
this.subscriptions.add(
this.apiService
.login(this.username, this.password)
.pipe(
catchError(() => {
this.failed = true;
return of(null);
}),
)
.subscribe((event) => {
if (event?.status == 200) {
this.router.navigateByUrl('/');
} else {
this.failed = true;
}
}),
);
}
ngOnDestroy() {
this.subscriptions.unsubscribe();
} }
} }

View file

@ -101,6 +101,11 @@ export class NavigationComponent {
url: '/alerts', url: '/alerts',
icon: 'notifications', icon: 'notifications',
}, },
{
name: 'Shares',
url: '/shares',
icon: 'share',
},
]; ];
toggleFilterPanel() { toggleFilterPanel() {

View file

@ -0,0 +1,17 @@
@let sh = share | async;
@switch (sh?.type) {
@case ("incident") {
<app-incident [share]="sh!"></app-incident>
}
@case ("call") {
<app-call-info [share]="sh!" player="true"></app-call-info>
}
@case (null) {
<div class="spinner">
<mat-spinner></mat-spinner>
</div>
}
@default {
<h1 class="error">Share type {{ sh?.type }} unknown</h1>
}
}

View file

@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ShareComponent } from './share.component';
describe('ShareComponent', () => {
let component: ShareComponent;
let fixture: ComponentFixture<ShareComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ShareComponent],
}).compileComponents();
fixture = TestBed.createComponent(ShareComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,41 @@
import { Component } from '@angular/core';
import { ShareService } from './share.service';
import { Share } from '../shares';
import { ActivatedRoute } from '@angular/router';
import { Observable, Subscription, switchMap } from 'rxjs';
import { IncidentComponent } from '../incidents/incident/incident.component';
import { AsyncPipe } from '@angular/common';
import { CallInfoComponent } from '../calls/call-info/call-info.component';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
@Component({
selector: 'app-share',
imports: [
AsyncPipe,
IncidentComponent,
CallInfoComponent,
MatProgressSpinnerModule,
],
templateUrl: './share.component.html',
styleUrl: './share.component.scss',
})
export class ShareComponent {
shareID!: string;
share!: Observable<Share>;
constructor(
private route: ActivatedRoute,
private shareSvc: ShareService,
) {}
ngOnInit() {
let shareParam = this.route.snapshot.paramMap.get('id');
if (shareParam == null) {
// TODO: error
return;
}
this.shareID = shareParam;
this.share = this.shareSvc.getShare(this.shareID);
}
}

View file

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ShareService } from './share.service';
describe('ShareService', () => {
let service: ShareService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ShareService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View file

@ -0,0 +1,81 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { map, Observable, switchMap } from 'rxjs';
import { IncidentRecord } from '../incidents';
import { CallRecord } from '../calls';
import { Share, ShareType } from '../shares';
import { ActivatedRoute, Router } from '@angular/router';
export interface ShareRecord {
id: string;
entityType: string;
entityDate: Date;
owner: string;
entityID: string;
expiration: Date | null;
}
export interface ShareListParams {
page: number | null;
perPage: number | null;
dir: string | null;
}
export interface Shares {
shares: ShareRecord[];
totalCount: number;
}
@Injectable({
providedIn: 'root',
})
export class ShareService {
constructor(
private http: HttpClient,
private router: Router,
private route: ActivatedRoute,
) {}
inShare(): string | null {
if (this.router.url.startsWith('/s/')) {
return this.route.snapshot.paramMap.get('id');
}
return null;
}
getShare(id: string): Observable<Share> {
return this.http.get<Share>(`/share/${id}`);
}
deleteShare(id: string): Observable<void> {
return this.http.delete<void>(`/api/share/${id}`);
}
getShares(p: ShareListParams): Observable<Shares> {
return this.http.post<Shares>('/api/share/', p);
}
getSharedItem(s: Observable<Share>): Observable<ShareType> {
return s.pipe(
map((res) => {
switch (res.type) {
case 'call':
return <CallRecord>res.sharedItem;
case 'incident':
return <IncidentRecord>res.sharedItem;
}
return null;
}),
);
}
getCallAudio(s: Observable<CallRecord>): Observable<ArrayBuffer> {
return s.pipe(
switchMap((res) => {
return this.http.get<ArrayBuffer>(res.audioURL!);
}),
);
}
}

View file

@ -0,0 +1,8 @@
import { IncidentRecord } from './incidents';
import { CallRecord } from './calls';
export type ShareType = IncidentRecord | CallRecord | null;
export interface Share {
id: string;
type: string;
sharedItem: ShareType;
}

View file

@ -0,0 +1,82 @@
<div class="tabContainer" *ngIf="!isLoading; else spinner">
<table class="sharesTable" mat-table [dataSource]="sharesResult">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox
(change)="$event ? masterToggle() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
>
</mat-checkbox>
</th>
<td mat-cell *matCellDef="let row">
<mat-checkbox
(click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(row) : null"
[checked]="selection.isSelected(row)"
>
</mat-checkbox>
</td>
</ng-container>
<ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let share">
@switch (share.entityType) {
@case ("incident") {
<mat-icon>newspaper</mat-icon>
}
@case ("call") {
<mat-icon>campaign</mat-icon>
}
}
</td>
</ng-container>
<ng-container matColumnDef="link">
<th mat-header-cell *matHeaderCellDef>Link</th>
<td mat-cell *matCellDef="let share">
<a [href]="'/s/' + share.id"><mat-icon>link</mat-icon></a>
</td>
</ng-container>
<ng-container matColumnDef="date">
<th mat-header-cell *matHeaderCellDef>Date</th>
<td mat-cell *matCellDef="let share">
{{ share.entityDate | fmtDate }}
</td>
</ng-container>
<ng-container matColumnDef="owner">
<th mat-header-cell *matHeaderCellDef>Owner</th>
<td mat-cell *matCellDef="let share">
{{ share.owner }}
</td>
</ng-container>
<ng-container matColumnDef="delete">
<th mat-header-cell *matHeaderCellDef>Delete</th>
<td mat-cell *matCellDef="let share">
<a (click)="deleteShare(share.id)">
<mat-icon>delete</mat-icon>
</a>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns; sticky: true"></tr>
<tr mat-row *matRowDef="let myRowData; columns: columns"></tr>
</table>
</div>
<div class="pagFoot">
<mat-paginator
#paginator
class="paginator"
(page)="setPage($event)"
[length]="count"
showFirstLastButtons="true"
[pageSize]="curPage.pageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="curPage.pageIndex"
aria-label="Select page"
>
</mat-paginator>
</div>
<ng-template #spinner>
<div class="spinner">
<mat-spinner></mat-spinner>
</div>
</ng-template>

View file

@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SharesComponent } from './shares.component';
describe('SharesComponent', () => {
let component: SharesComponent;
let fixture: ComponentFixture<SharesComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SharesComponent],
}).compileComponents();
fixture = TestBed.createComponent(SharesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,192 @@
import { Component, Pipe, PipeTransform, ViewChild } from '@angular/core';
import { CommonModule, AsyncPipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTableModule } from '@angular/material/table';
import {
MatPaginator,
MatPaginatorModule,
PageEvent,
} from '@angular/material/paginator';
import { PrefsService } from '../prefs/prefs.service';
import { MatIconModule } from '@angular/material/icon';
import { SelectionModel } from '@angular/cdk/collections';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { BehaviorSubject, Subscription } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { TalkgroupService } from '../talkgroups/talkgroups.service';
import { MatFormFieldModule } from '@angular/material/form-field';
import {
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
} from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { debounceTime } from 'rxjs/operators';
import { ToolbarContextService } from '../navigation/toolbar-context.service';
import {
ShareListParams,
ShareRecord,
ShareService,
} from '../share/share.service';
import { FmtDatePipe } from '../incidents/incidents.component';
const reqPageSize = 200;
@Component({
selector: 'app-shares',
imports: [
MatIconModule,
MatPaginatorModule,
MatTableModule,
MatFormFieldModule,
ReactiveFormsModule,
FormsModule,
FmtDatePipe,
MatInputModule,
MatCheckboxModule,
CommonModule,
MatProgressSpinnerModule,
],
templateUrl: './shares.component.html',
styleUrl: './shares.component.scss',
})
export class SharesComponent {
sharesResult = new BehaviorSubject(new Array<ShareRecord>(0));
selection = new SelectionModel<ShareRecord>(true, []);
@ViewChild('paginator') paginator!: MatPaginator;
count = 0;
curLen = 0;
page = 0;
perPage = 25;
pageSizeOptions = [25, 50, 75, 100, 200];
columns = ['select', 'type', 'link', 'date', 'owner', 'delete'];
curPage = <PageEvent>{ pageIndex: 0, pageSize: 0 };
currentSet!: ShareRecord[];
currentServerPage = 0; // page is never 0, forces load
isLoading = true;
subscriptions = new Subscription();
pageWindow = 0;
fetchIncidents = new BehaviorSubject<ShareListParams>(
this.buildParams(this.curPage, this.curPage.pageIndex),
);
constructor(private sharesSvc: ShareService) {}
isAllSelected() {
const numSelected = this.selection.selected.length;
const numRows = this.curLen;
return numSelected === numRows;
}
buildParams(p: PageEvent, serverPage: number): ShareListParams {
const par: ShareListParams = {
page: serverPage,
perPage: reqPageSize,
dir: 'asc',
};
return par;
}
masterToggle() {
this.isAllSelected()
? this.selection.clear()
: this.sharesResult.value.forEach((row) => this.selection.select(row));
}
setPage(p: PageEvent, force?: boolean) {
this.selection.clear();
this.curPage = p;
if (p && p!.pageSize != this.perPage) {
this.perPage = p!.pageSize;
}
this.getShares(p, force);
}
refresh() {
this.selection.clear();
this.getShares(this.curPage, true);
}
getShares(p: PageEvent, force?: boolean) {
const pageStart = p.pageIndex * p.pageSize;
const serverPage = Math.floor(pageStart / reqPageSize) + 1;
this.pageWindow = pageStart % reqPageSize;
if (serverPage == this.currentServerPage && !force && this.currentSet) {
this.sharesResult.next(
this.sharesResult
? this.currentSet.slice(this.pageWindow, this.pageWindow + p.pageSize)
: [],
);
} else {
this.currentServerPage = serverPage;
this.fetchIncidents.next(this.buildParams(p, serverPage));
}
}
zeroPage(): PageEvent {
return <PageEvent>{
pageIndex: 0,
pageSize: this.curPage.pageSize,
};
}
ngOnDestroy() {
this.subscriptions.unsubscribe();
}
ngOnInit() {
let cpp = 25;
this.perPage = cpp;
this.setPage(<PageEvent>{
pageIndex: 0,
pageSize: cpp,
});
this.subscriptions.add(
this.fetchIncidents
.pipe(
switchMap((params) => {
return this.sharesSvc.getShares(params);
}),
)
.subscribe((shares) => {
this.isLoading = false;
this.count = shares.totalCount;
this.currentSet = shares.shares;
this.sharesResult.next(
this.currentSet
? this.currentSet.slice(
this.pageWindow,
this.pageWindow + this.perPage,
)
: [],
);
}),
);
this.subscriptions.add(
this.sharesResult.subscribe((cr) => {
this.curLen = cr.length;
}),
);
}
deleteShare(shareID: string) {
if (confirm('Are you sure you want to delete this share?')) {
this.sharesSvc.deleteShare(shareID).subscribe({
next: () => {
this.fetchIncidents.next(
this.buildParams(this.curPage, this.curPage.pageIndex),
);
},
error: (err) => {
alert(err);
},
});
}
}
}

View file

@ -58,43 +58,39 @@ export const iconMapping: IconMap = {
export class Talkgroup { export class Talkgroup {
id!: number; id!: number;
system_id!: number; systemId!: number;
tgid!: number; tgid!: number;
name!: string; name!: string;
alpha_tag!: string; alphaTag!: string;
tg_group!: string; tgGroup!: string;
frequency!: number; frequency!: number;
metadata!: Metadata | null; metadata!: Metadata | null;
tags!: string[]; tags!: string[];
alert!: boolean; alert!: boolean;
system?: System; system?: System;
alert_rules!: AlertRule[]; alertRules!: AlertRule[];
weight!: number; weight!: number;
learned?: boolean; learned?: boolean;
icon?: string; icon?: string;
iconSvg?: string; iconSvg?: string;
constructor( constructor(
id: number, id: number,
system_id: number, systemId: number,
tgid: number, tgid: number,
name: string, name: string,
alpha_tag: string, alphaTag: string,
tg_group: string, tgGroup: string,
frequency: number, frequency: number,
metadata: Metadata | null, metadata: Metadata | null,
tags: string[], tags: string[],
alert: boolean, alert: boolean,
alert_rules: AlertRule[], alertRules: AlertRule[],
weight: number, weight: number,
system?: System, system?: System,
learned?: boolean, learned?: boolean,
icon?: string, icon?: string,
) { ) {
this.iconSvg = this.iconMap(this.metadata?.icon!); this.iconSvg = this.iconMap(this.metadata?.icon!);
this.alert_rules = this.alert_rules.map((x) =>
Object.assign(new AlertRule(), x),
);
console.log(this.alert_rules);
} }
iconMap(icon: string): string { iconMap(icon: string): string {
@ -103,7 +99,7 @@ export class Talkgroup {
tgTuple(): TGID { tgTuple(): TGID {
return <TGID>{ return <TGID>{
sys: this.system_id, sys: this.systemId,
tg: this.tgid, tg: this.tgid,
}; };
} }
@ -115,15 +111,15 @@ export interface TalkgroupUI extends Talkgroup {
export interface TalkgroupUpdate { export interface TalkgroupUpdate {
id: number; id: number;
system_id: number; systemId: number;
tgid: number; tgid: number;
name: string | null; name: string | null;
alpha_tag: string | null; alphaTag: string | null;
tg_group: string | null; tgGroup: string | null;
frequency: number | null; frequency: number | null;
metadata: Object | null; metadata: Object | null;
tags: string[] | null; tags: string[] | null;
alert: boolean | null; alert: boolean | null;
alert_rules: AlertRule[] | null; alertRules: AlertRule[] | null;
weight: number | null; weight: number | null;
} }

View file

@ -55,8 +55,8 @@
</td> </td>
<td>{{ tg.system?.name }}</td> <td>{{ tg.system?.name }}</td>
<td>{{ tg.system?.id }}</td> <td>{{ tg.system?.id }}</td>
<td>{{ tg.tg_group }}</td> <td>{{ tg.tgGroup }}</td>
<td>{{ tg.alpha_tag }}</td> <td>{{ tg.alphaTag }}</td>
<td>{{ tg.name }}</td> <td>{{ tg.name }}</td>
<td>{{ tg.tgid }}</td> <td>{{ tg.tgid }}</td>
<td>{{ tg?.metadata?.encrypted ? "E" : "" }}</td> <td>{{ tg?.metadata?.encrypted ? "E" : "" }}</td>

View file

@ -15,9 +15,9 @@
<mat-label>Alpha Tag</mat-label <mat-label>Alpha Tag</mat-label
><input ><input
matInput matInput
name="alpha_tag" name="alphaTag"
type="text" type="text"
formControlName="alpha_tag" formControlName="alphaTag"
/> />
</mat-form-field> </mat-form-field>
</div> </div>
@ -26,9 +26,9 @@
<mat-label>Group</mat-label <mat-label>Group</mat-label
><input ><input
matInput matInput
name="tg_group" name="tgTroup"
type="text" type="text"
formControlName="tg_group" formControlName="tgGroup"
/> />
</mat-form-field> </mat-form-field>
</div> </div>
@ -108,7 +108,7 @@
> >
</div> </div>
<div class="alert"> <div class="alert">
<alert-rule-builder [rules]="tg.alert_rules" /> <alert-rule-builder [rules]="tg.alertRules" />
</div> </div>
</form> </form>
} @else { } @else {

View file

@ -84,8 +84,8 @@ export class TalkgroupRecordComponent {
readonly _allTags: Observable<string[]>; readonly _allTags: Observable<string[]>;
form = new FormGroup({ form = new FormGroup({
name: new FormControl(''), name: new FormControl(''),
alpha_tag: new FormControl(''), alphaTag: new FormControl(''),
tg_group: new FormControl(''), tgGroup: new FormControl(''),
frequency: new FormControl(0), frequency: new FormControl(0),
alert: new FormControl(false), alert: new FormControl(false),
weight: new FormControl(0.0), weight: new FormControl(0.0),
@ -158,9 +158,8 @@ export class TalkgroupRecordComponent {
.getTalkgroup(Number(this.tgid.sys), Number(this.tgid.tg)) .getTalkgroup(Number(this.tgid.sys), Number(this.tgid.tg))
.pipe( .pipe(
tap((tg) => { tap((tg) => {
console.log('tap run'); tg.alertRules = tg.alertRules
tg.alert_rules = tg.alert_rules ? tg.alertRules.map((x) => Object.assign(new AlertRule(), x))
? tg.alert_rules.map((x) => Object.assign(new AlertRule(), x))
: []; : [];
this.form.patchValue(tg); this.form.patchValue(tg);
this.form.controls['tagInput'].setValue(''); this.form.controls['tagInput'].setValue('');
@ -181,17 +180,17 @@ export class TalkgroupRecordComponent {
save() { save() {
let tgu: TalkgroupUpdate = <TalkgroupUpdate>{ let tgu: TalkgroupUpdate = <TalkgroupUpdate>{
system_id: this.tgid.sys, systemId: this.tgid.sys,
tgid: this.tgid.tg, tgid: this.tgid.tg,
}; };
if (this.form.controls['name'].dirty) { if (this.form.controls['name'].dirty) {
tgu.name = this.form.controls['name'].value; tgu.name = this.form.controls['name'].value;
} }
if (this.form.controls['alpha_tag'].dirty) { if (this.form.controls['alphaTag'].dirty) {
tgu.alpha_tag = this.form.controls['alpha_tag'].value; tgu.alphaTag = this.form.controls['alphaTag'].value;
} }
if (this.form.controls['tg_group'].dirty) { if (this.form.controls['tgGroup'].dirty) {
tgu.tg_group = this.form.controls['tg_group'].value; tgu.tgGroup = this.form.controls['tgGroup'].value;
} }
if (this.form.controls['frequency'].dirty) { if (this.form.controls['frequency'].dirty) {
tgu.frequency = this.form.controls['frequency'].value; tgu.frequency = this.form.controls['frequency'].value;

View file

@ -34,7 +34,7 @@
</ng-container> </ng-container>
<ng-container matColumnDef="group"> <ng-container matColumnDef="group">
<th mat-header-cell *matHeaderCellDef>Group</th> <th mat-header-cell *matHeaderCellDef>Group</th>
<td mat-cell *matCellDef="let tg">{{ tg.tg_group }}</td> <td mat-cell *matCellDef="let tg">{{ tg.tgGroup }}</td>
</ng-container> </ng-container>
<ng-container matColumnDef="name"> <ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th> <th mat-header-cell *matHeaderCellDef>Name</th>
@ -42,7 +42,7 @@
</ng-container> </ng-container>
<ng-container matColumnDef="alphaTag"> <ng-container matColumnDef="alphaTag">
<th mat-header-cell *matHeaderCellDef>Alpha Tag</th> <th mat-header-cell *matHeaderCellDef>Alpha Tag</th>
<td mat-cell *matCellDef="let tg">{{ tg.alpha_tag }}</td> <td mat-cell *matCellDef="let tg">{{ tg.alphaTag }}</td>
</ng-container> </ng-container>
<ng-container matColumnDef="tgid"> <ng-container matColumnDef="tgid">
<th mat-header-cell *matHeaderCellDef>TG ID</th> <th mat-header-cell *matHeaderCellDef>TG ID</th>

View file

@ -2,14 +2,17 @@ import { Injectable } from '@angular/core';
import { HttpClient, HttpResponse } from '@angular/common/http'; import { HttpClient, HttpResponse } from '@angular/common/http';
import { import {
BehaviorSubject, BehaviorSubject,
concatMap,
Observable, Observable,
ReplaySubject, ReplaySubject,
shareReplay, shareReplay,
Subject,
Subscription, Subscription,
switchMap, switchMap,
} from 'rxjs'; } from 'rxjs';
import { Talkgroup, TalkgroupUpdate, TGID } from '../talkgroup'; import { Talkgroup, TalkgroupUpdate, TGID } from '../talkgroup';
import { Share } from '../shares';
import { ShareService } from '../share/share.service';
import { AuthService } from '../login/auth.service';
export interface Pagination { export interface Pagination {
page: number; page: number;
@ -29,30 +32,57 @@ export class TalkgroupService {
private readonly _getTalkgroup = new Map<string, ReplaySubject<Talkgroup>>(); private readonly _getTalkgroup = new Map<string, ReplaySubject<Talkgroup>>();
private tgs$: Observable<Talkgroup[]>; private tgs$: Observable<Talkgroup[]>;
private tags$!: Observable<string[]>; private tags$!: Observable<string[]>;
private fetchAll = new BehaviorSubject<'fetch'>('fetch'); private fetchAll = new ReplaySubject<Share | null>();
private subscriptions = new Subscription(); private subscriptions = new Subscription();
constructor(private http: HttpClient) { constructor(
this.tgs$ = this.fetchAll.pipe(switchMap(() => this.getTalkgroups())); private http: HttpClient,
private shareSvc: ShareService,
private authSvc: AuthService,
) {
this.tgs$ = this.fetchAll.pipe(
switchMap((share) => this.getTalkgroups(share)),
shareReplay(),
);
this.tags$ = this.fetchAll.pipe( this.tags$ = this.fetchAll.pipe(
switchMap(() => this.getAllTags()), switchMap(() => this.getAllTags()),
shareReplay(), shareReplay(),
); );
let sh = this.shareSvc.inShare();
if (sh) {
this.shareSvc.getShare(sh).subscribe(this.fetchAll);
} else {
if (this.authSvc.isAuth()) {
this.fetchAll.next(null);
}
}
this.fillTgMap(); this.fillTgMap();
} }
setShare(share: Share | null) {
if (!this.authSvc.isAuth() && share !== null) {
this.fetchAll.next(share);
}
}
ngOnDestroy() { ngOnDestroy() {
this.subscriptions.unsubscribe(); this.subscriptions.unsubscribe();
} }
getAllTags(): Observable<string[]> { getAllTags(): Observable<string[]> {
return this.http.get<string[]>('/api/talkgroup/tags').pipe(shareReplay()); return this.http.get<string[]>('/api/talkgroup/tags');
} }
getTalkgroups(): Observable<Talkgroup[]> { getTalkgroups(share: Share | null): Observable<Talkgroup[]> {
return this.http.get<Talkgroup[]>('/api/talkgroup/').pipe(shareReplay()); return this.http.get<Talkgroup[]>(
share ? `/share/${share.id}/talkgroups` : '/api/talkgroup/',
);
} }
getTalkgroup(sys: number, tg: number): Observable<Talkgroup> { getTalkgroup(
sys: number,
tg: number,
share: Share | null = null,
): Observable<Talkgroup> {
const key = this.tgKey(sys, tg); const key = this.tgKey(sys, tg);
if (!this._getTalkgroup.get(key)) { if (!this._getTalkgroup.get(key)) {
let rs = new ReplaySubject<Talkgroup>(); let rs = new ReplaySubject<Talkgroup>();
@ -62,10 +92,10 @@ export class TalkgroupService {
} }
putTalkgroup(tu: TalkgroupUpdate): Observable<Talkgroup> { putTalkgroup(tu: TalkgroupUpdate): Observable<Talkgroup> {
let tgid = this.tgKey(tu.system_id, tu.tgid); let tgid = this.tgKey(tu.systemId, tu.tgid);
return this.http return this.http
.put<Talkgroup>(`/api/talkgroup/${tu.system_id}/${tu.tgid}`, tu) .put<Talkgroup>(`/api/talkgroup/${tu.systemId}/${tu.tgid}`, tu)
.pipe( .pipe(
switchMap((tg) => { switchMap((tg) => {
let tObs = this._getTalkgroup.get(tgid); let tObs = this._getTalkgroup.get(tgid);
@ -100,7 +130,7 @@ export class TalkgroupService {
this.subscriptions.add( this.subscriptions.add(
this.tgs$.subscribe((tgs) => { this.tgs$.subscribe((tgs) => {
tgs.forEach((tg) => { tgs.forEach((tg) => {
let tgid = this.tgKey(tg.system_id, tg.tgid); let tgid = this.tgKey(tg.systemId, tg.tgid);
const rs = this._getTalkgroup.get(tgid); const rs = this._getTalkgroup.get(tgid);
if (rs) { if (rs) {
(rs as ReplaySubject<Talkgroup>).next(tg); (rs as ReplaySubject<Talkgroup>).next(tg);

View file

@ -1,5 +1,9 @@
{ {
"/api": { "/api/": {
"target": "http://xenon:3050",
"secure": false
},
"/share/": {
"target": "http://xenon:3050", "target": "http://xenon:3050",
"secure": false "secure": false
} }

View file

@ -1,9 +1,11 @@
package main package main
import ( import (
"bufio"
"encoding/json" "encoding/json"
"errors" "errors"
"flag" "flag"
"fmt"
"log" "log"
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
@ -11,10 +13,12 @@ import (
"os" "os"
"os/signal" "os/signal"
"strings" "strings"
"syscall"
"time" "time"
"dynatron.me/x/stillbox/internal/version" "dynatron.me/x/stillbox/internal/version"
"dynatron.me/x/stillbox/pkg/pb" "dynatron.me/x/stillbox/pkg/pb"
"golang.org/x/term"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
@ -37,6 +41,31 @@ func userAgent(h http.Header) {
h.Set("User-Agent", uaString) h.Set("User-Agent", uaString)
} }
func getCreds() {
rdr := bufio.NewReader(os.Stdin)
if username == nil || *username == "" {
fmt.Print("Username: ")
un, err := rdr.ReadString('\n')
if err != nil {
panic(err)
}
username = &un
}
if password == nil || *password == "" {
fmt.Print("Password: ")
bytePass, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
panic(err)
}
pS := string(bytePass)
pS = strings.Trim(pS, "\n")
password = &pS
}
}
func main() { func main() {
flag.Parse() flag.Parse()
log.SetFlags(0) log.SetFlags(0)
@ -53,6 +82,8 @@ func main() {
signal.Notify(interrupt, os.Interrupt) signal.Notify(interrupt, os.Interrupt)
play := NewPlayer() play := NewPlayer()
getCreds()
loginForm := url.Values{} loginForm := url.Values{}
loginForm.Add("username", *username) loginForm.Add("username", *username)
loginForm.Add("password", *password) loginForm.Add("password", *password)

10
go.mod
View file

@ -5,6 +5,7 @@ go 1.23.2
require ( require (
dynatron.me/x/go-minimp3 v0.0.0-20240805171536-7ea857e216d6 dynatron.me/x/go-minimp3 v0.0.0-20240805171536-7ea857e216d6
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/el-mike/restrict/v2 v2.0.0
github.com/go-audio/wav v1.1.0 github.com/go-audio/wav v1.1.0
github.com/go-chi/chi/v5 v5.1.0 github.com/go-chi/chi/v5 v5.1.0
github.com/go-chi/cors v1.2.1 github.com/go-chi/cors v1.2.1
@ -21,6 +22,8 @@ require (
github.com/knadh/koanf/providers/env v1.0.0 github.com/knadh/koanf/providers/env v1.0.0
github.com/knadh/koanf/providers/file v1.1.2 github.com/knadh/koanf/providers/file v1.1.2
github.com/knadh/koanf/v2 v2.1.2 github.com/knadh/koanf/v2 v2.1.2
github.com/lestrrat-go/jwx/v2 v2.1.3
github.com/matoous/go-nanoid v1.5.1
github.com/nikoksr/notify v1.1.0 github.com/nikoksr/notify v1.1.0
github.com/rs/zerolog v1.33.0 github.com/rs/zerolog v1.33.0
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
@ -28,7 +31,7 @@ require (
github.com/urfave/cli/v2 v2.27.5 github.com/urfave/cli/v2 v2.27.5
golang.org/x/crypto v0.29.0 golang.org/x/crypto v0.29.0
golang.org/x/sync v0.9.0 golang.org/x/sync v0.9.0
golang.org/x/term v0.26.0 golang.org/x/term v0.28.0
google.golang.org/protobuf v1.35.2 google.golang.org/protobuf v1.35.2
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@ -39,7 +42,6 @@ require (
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
github.com/el-mike/restrict/v2 v2.0.0 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-audio/audio v1.0.0 // indirect github.com/go-audio/audio v1.0.0 // indirect
github.com/go-audio/riff v1.0.0 // indirect github.com/go-audio/riff v1.0.0 // indirect
@ -55,9 +57,7 @@ require (
github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/jwx/v2 v2.1.3 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect github.com/lestrrat-go/option v1.0.1 // indirect
github.com/matoous/go-nanoid v1.5.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect
@ -71,6 +71,6 @@ require (
golang.org/x/exp/shiny v0.0.0-20241108190413-2d47ceb2692f // indirect golang.org/x/exp/shiny v0.0.0-20241108190413-2d47ceb2692f // indirect
golang.org/x/image v0.22.0 // indirect golang.org/x/image v0.22.0 // indirect
golang.org/x/mobile v0.0.0-20241108191957-fa514ef75a0f // indirect golang.org/x/mobile v0.0.0-20241108191957-fa514ef75a0f // indirect
golang.org/x/sys v0.27.0 // indirect golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.20.0 // indirect golang.org/x/text v0.20.0 // indirect
) )

23
go.sum
View file

@ -8,12 +8,6 @@ github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.103.0 h1:dHElatNXNrr8XcseUov0ZSiWjauwmZZE6YMV3eU1yic=
github.com/casbin/casbin/v2 v2.103.0/go.mod h1:Ee33aqGrmES+GNL17L0h9X28wXuo829wnNUnS0edAco=
github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
@ -69,7 +63,6 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y= github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks= github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -117,8 +110,6 @@ github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCG
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx/v2 v2.1.2 h1:6poete4MPsO8+LAEVhpdrNI4Xp2xdiafgl2RD89moBc=
github.com/lestrrat-go/jwx/v2 v2.1.2/go.mod h1:pO+Gz9whn7MPdbsqSJzG8TlEpMZCwQDXnFJ+zsUVh8Y=
github.com/lestrrat-go/jwx/v2 v2.1.3 h1:Ud4lb2QuxRClYAmRleF50KrbKIoM1TddXgBrneT5/Jo= github.com/lestrrat-go/jwx/v2 v2.1.3 h1:Ud4lb2QuxRClYAmRleF50KrbKIoM1TddXgBrneT5/Jo=
github.com/lestrrat-go/jwx/v2 v2.1.3/go.mod h1:q6uFgbgZfEmQrfJfrCo90QcQOcXFMfbI/fO0NqRtvZo= github.com/lestrrat-go/jwx/v2 v2.1.3/go.mod h1:q6uFgbgZfEmQrfJfrCo90QcQOcXFMfbI/fO0NqRtvZo=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
@ -176,7 +167,6 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
@ -196,7 +186,6 @@ go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HY
go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@ -208,24 +197,20 @@ golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mobile v0.0.0-20241108191957-fa514ef75a0f h1:23H/YlmTHfmmvpZ+ajKZL0qLz0+IwFOIqQA0mQbmLeM= golang.org/x/mobile v0.0.0-20241108191957-fa514ef75a0f h1:23H/YlmTHfmmvpZ+ajKZL0qLz0+IwFOIqQA0mQbmLeM=
golang.org/x/mobile v0.0.0-20241108191957-fa514ef75a0f/go.mod h1:UbSUP4uu/C9hw9R2CkojhXlAxvayHjBdU9aRvE+c1To= golang.org/x/mobile v0.0.0-20241108191957-fa514ef75a0f/go.mod h1:UbSUP4uu/C9hw9R2CkojhXlAxvayHjBdU9aRvE+c1To=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View file

@ -38,6 +38,7 @@ type Alerter interface {
Enabled() bool Enabled() bool
Go(context.Context) Go(context.Context)
HUP(*config.Config)
stats stats
} }
@ -101,9 +102,7 @@ func New(cfg config.Alerting, tgCache tgstore.Store, opts ...AlertOption) Alerte
tgCache: tgCache, tgCache: tgCache,
} }
if cfg.Renotify != nil { as.reload()
as.renotify = cfg.Renotify.Duration()
}
for _, opt := range opts { for _, opt := range opts {
opt(as) opt(as)
@ -122,6 +121,21 @@ func New(cfg config.Alerting, tgCache tgstore.Store, opts ...AlertOption) Alerte
return as return as
} }
func (as *alerter) reload() {
if as.cfg.Renotify != nil {
as.renotify = as.cfg.Renotify.Duration()
}
}
func (as *alerter) HUP(cfg *config.Config) {
as.Lock()
defer as.Unlock()
log.Debug().Msg("reloading alert config")
as.cfg = cfg.Alerting
as.reload()
}
// Go is the alerting loop. It does not start a goroutine. // Go is the alerting loop. It does not start a goroutine.
func (as *alerter) Go(ctx context.Context) { func (as *alerter) Go(ctx context.Context) {
ctx = entities.CtxWithSubject(ctx, &entities.SystemServiceSubject{Name: "alerter"}) ctx = entities.CtxWithSubject(ctx, &entities.SystemServiceSubject{Name: "alerter"})
@ -166,7 +180,12 @@ func (as *alerter) eval(ctx context.Context, now time.Time, testMode bool) ([]al
for _, s := range as.scores { for _, s := range as.scores {
origScore := s.Score origScore := s.Score
tgr, err := as.tgCache.TG(ctx, s.ID) tgr, err := as.tgCache.TG(ctx, s.ID)
if err != nil || !tgr.Talkgroup.Alert { if err != nil {
log.Error().Err(err).Msg("alerting eval tg get")
continue
}
if !tgr.Talkgroup.Alert {
continue continue
} }
@ -376,3 +395,4 @@ func (*noopAlerter) SinkType() string { return "noopA
func (*noopAlerter) Call(_ context.Context, _ *calls.Call) error { return nil } func (*noopAlerter) Call(_ context.Context, _ *calls.Call) error { return nil }
func (*noopAlerter) Go(_ context.Context) {} func (*noopAlerter) Go(_ context.Context) {}
func (*noopAlerter) Enabled() bool { return false } func (*noopAlerter) Enabled() bool { return false }
func (*noopAlerter) HUP(_ *config.Config) {}

View file

@ -34,8 +34,8 @@ type jwtAuth interface {
// InstallVerifyMiddleware installs the JWT verifier middleware to the provided chi Router. // InstallVerifyMiddleware installs the JWT verifier middleware to the provided chi Router.
VerifyMiddleware() func(http.Handler) http.Handler VerifyMiddleware() func(http.Handler) http.Handler
// InstallAuthMiddleware installs the JWT authenticator middleware to the provided chi Router. // SubjectMiddleware sets the request context subject from JWT or public.
AuthMiddleware() func(http.Handler) http.Handler SubjectMiddleware(requireAuth bool) func(http.Handler) http.Handler
// PublicRoutes installs the auth route to the provided chi Router. // PublicRoutes installs the auth route to the provided chi Router.
PublicRoutes(chi.Router) PublicRoutes(chi.Router)
@ -84,22 +84,39 @@ func TokenFromCookie(r *http.Request) string {
return cookie.Value return cookie.Value
} }
func (a *Auth) AuthMiddleware() func(http.Handler) http.Handler { func (a *Auth) PublicSubjectMiddleware() func(http.Handler) http.Handler {
return a.SubjectMiddleware(false)
}
func (a *Auth) AuthorizedSubjectMiddleware() func(http.Handler) http.Handler {
return a.SubjectMiddleware(true)
}
func (a *Auth) SubjectMiddleware(requireToken bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
hfn := func(w http.ResponseWriter, r *http.Request) { hfn := func(w http.ResponseWriter, r *http.Request) {
token, _, err := jwtauth.FromContext(r.Context()) token, _, err := jwtauth.FromContext(r.Context())
if err != nil { if err != nil && requireToken {
http.Error(w, err.Error(), http.StatusUnauthorized) http.Error(w, err.Error(), http.StatusUnauthorized)
return return
} }
if token != nil && jwt.Validate(token, a.jwt.ValidateOptions()...) == nil { ctx := r.Context()
ctx := r.Context()
if token != nil {
err := jwt.Validate(token, a.jwt.ValidateOptions()...)
if err != nil {
err = jwtauth.ErrorReason(err)
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
username := token.Subject() username := token.Subject()
sub, err := users.FromCtx(ctx).GetUser(ctx, username) sub, err := users.FromCtx(ctx).GetUser(ctx, username)
if err != nil { if err != nil {
log.Error().Str("username", username).Err(err).Msg("subject middleware get subject")
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return return
} }
@ -111,8 +128,9 @@ func (a *Auth) AuthMiddleware() func(http.Handler) http.Handler {
return return
} }
// Token is authenticated, pass it through // Public subject
next.ServeHTTP(w, r) ctx = entities.CtxWithSubject(ctx, entities.NewPublicSubject(r))
next.ServeHTTP(w, r.WithContext(ctx))
} }
return http.HandlerFunc(hfn) return http.HandlerFunc(hfn)
} }
@ -211,7 +229,7 @@ func (a *Auth) routeRefresh(w http.ResponseWriter, r *http.Request) {
} }
if cookie.Secure { if cookie.Secure {
cookie.Domain = r.Host cookie.Domain = strings.Split(r.Host, ":")[0]
} }
http.SetCookie(w, cookie) http.SetCookie(w, cookie)
@ -271,7 +289,7 @@ func (a *Auth) routeAuth(w http.ResponseWriter, r *http.Request) {
MaxAge: 60 * 60 * 24 * 30, // one month MaxAge: 60 * 60 * 24 * 30, // one month
} }
cookie.Domain = r.Host cookie.Domain = strings.Split(r.Host, ":")[0]
if a.allowInsecureCookie(r) { if a.allowInsecureCookie(r) {
a.setInsecureCookie(cookie) a.setInsecureCookie(cookie)
} }
@ -297,7 +315,7 @@ func (a *Auth) routeLogout(w http.ResponseWriter, r *http.Request) {
MaxAge: -1, MaxAge: -1,
} }
cookie.Domain = r.Host cookie.Domain = strings.Split(r.Host, ":")[0]
if a.allowInsecureCookie(r) { if a.allowInsecureCookie(r) {
cookie.Secure = true cookie.Secure = true
cookie.SameSite = http.SameSiteNoneMode cookie.SameSite = http.SameSiteNoneMode

View file

@ -3,9 +3,11 @@ package calls
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url"
"time" "time"
"dynatron.me/x/stillbox/internal/audio" "dynatron.me/x/stillbox/internal/audio"
"dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/pb" "dynatron.me/x/stillbox/pkg/pb"
"dynatron.me/x/stillbox/pkg/rbac/entities" "dynatron.me/x/stillbox/pkg/rbac/entities"
@ -22,6 +24,13 @@ func (d CallDuration) Duration() time.Duration {
return time.Duration(d) return time.Duration(d)
} }
func (d CallDuration) ColonFormat() string {
dur := d.Duration().Round(time.Second)
m := dur / time.Minute
s := dur / time.Second
return fmt.Sprintf("%d:%02d", m, s)
}
func (d CallDuration) MsInt32Ptr() *int32 { func (d CallDuration) MsInt32Ptr() *int32 {
if time.Duration(d) == 0 { if time.Duration(d) == 0 {
return nil return nil
@ -59,18 +68,19 @@ type Call struct {
AudioType string `json:"audioType,omitempty" relayOut:"audioType,omitempty"` AudioType string `json:"audioType,omitempty" relayOut:"audioType,omitempty"`
AudioURL *string `json:"audioURL,omitempty" relayOut:"audioURL,omitempty"` AudioURL *string `json:"audioURL,omitempty" relayOut:"audioURL,omitempty"`
Duration CallDuration `json:"duration,omitempty" relayOut:"duration,omitempty"` Duration CallDuration `json:"duration,omitempty" relayOut:"duration,omitempty"`
DateTime time.Time `json:"call_date,omitempty" relayOut:"dateTime,omitempty"` DateTime time.Time `json:"callDate,omitempty" relayOut:"dateTime,omitempty"`
Frequencies []int `json:"frequencies,omitempty" relayOut:"frequencies,omitempty"` Frequencies []int `json:"frequencies,omitempty" relayOut:"frequencies,omitempty"`
Frequency int `json:"frequency,omitempty" relayOut:"frequency,omitempty"` Frequency int `json:"frequency,omitempty" relayOut:"frequency,omitempty"`
Patches []int `json:"patches,omitempty" relayOut:"patches,omitempty"` Patches []int `json:"patches,omitempty" relayOut:"patches,omitempty"`
Source int `json:"source,omitempty" relayOut:"source,omitempty"` Source int `json:"source,omitempty" relayOut:"source,omitempty"`
System int `json:"system_id,omitempty" relayOut:"system,omitempty"` System int `json:"systemId,omitempty" relayOut:"system,omitempty"`
Submitter *users.UserID `json:"submitter,omitempty" relayOut:"submitter,omitempty"` Submitter *users.UserID `json:"submitter,omitempty" relayOut:"submitter,omitempty"`
SystemLabel string `json:"system_name,omitempty" relayOut:"systemLabel,omitempty"` SystemLabel string `json:"systemName,omitempty" relayOut:"systemLabel,omitempty"`
Talkgroup int `json:"tgid,omitempty" relayOut:"talkgroup,omitempty"` Talkgroup int `json:"tgid,omitempty" relayOut:"talkgroup,omitempty"`
TalkgroupGroup *string `json:"talkgroupGroup,omitempty" relayOut:"talkgroupGroup,omitempty"` TalkgroupGroup *string `json:"talkgroupGroup,omitempty" relayOut:"talkgroupGroup,omitempty"`
TalkgroupLabel *string `json:"talkgroupLabel,omitempty" relayOut:"talkgroupLabel,omitempty"` TalkgroupLabel *string `json:"talkgroupLabel,omitempty" relayOut:"talkgroupLabel,omitempty"`
TGAlphaTag *string `json:"tg_name,omitempty" relayOut:"talkgroupTag,omitempty"` TGAlphaTag *string `json:"tgAlphaTag,omitempty" relayOut:"talkgroupTag,omitempty"`
Transcript *string `json:"transcript" relayOut:"transcript,omitempty"`
shouldStore bool `json:"-"` shouldStore bool `json:"-"`
} }
@ -87,6 +97,15 @@ func (c *Call) ShouldStore() bool {
return c.shouldStore return c.shouldStore
} }
func (c *Call) SetShareURL(baseURL url.URL, shareID string) {
if c.AudioURL != nil {
return
}
baseURL.Path = fmt.Sprintf("/share/%s/call", shareID)
c.AudioURL = common.PtrTo(baseURL.String())
}
func Make(call *Call, dontStore bool) (*Call, error) { func Make(call *Call, dontStore bool) (*Call, error) {
err := call.computeLength() err := call.computeLength()
if err != nil { if err != nil {

View file

@ -74,6 +74,7 @@ func toAddCallParams(call *calls.Call) database.AddCallParams {
AudioName: common.NilIfZero(call.AudioName), AudioName: common.NilIfZero(call.AudioName),
AudioBlob: call.Audio, AudioBlob: call.Audio,
AudioType: common.NilIfZero(call.AudioType), AudioType: common.NilIfZero(call.AudioType),
AudioUrl: call.AudioURL,
Duration: call.Duration.MsInt32Ptr(), Duration: call.Duration.MsInt32Ptr(),
Frequency: call.Frequency, Frequency: call.Frequency,
Frequencies: call.Frequencies, Frequencies: call.Frequencies,
@ -145,7 +146,7 @@ func (s *store) CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio,
} }
func (s *store) Call(ctx context.Context, id uuid.UUID) (*calls.Call, error) { func (s *store) Call(ctx context.Context, id uuid.UUID) (*calls.Call, error) {
_, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceCall), rbac.WithActions(entities.ActionRead)) _, err := rbac.Check(ctx, &calls.Call{ID: id}, rbac.WithActions(entities.ActionRead))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -178,6 +179,7 @@ func (s *store) Call(ctx context.Context, id uuid.UUID) (*calls.Call, error) {
TalkgroupLabel: c.TGLabel, TalkgroupLabel: c.TGLabel,
TalkgroupGroup: c.TGGroup, TalkgroupGroup: c.TGGroup,
TGAlphaTag: c.TGAlphaTag, TGAlphaTag: c.TGAlphaTag,
Transcript: c.Transcript,
}, nil }, nil
} }

View file

@ -57,7 +57,7 @@ type StoreTGVersionBatchResults struct {
type StoreTGVersionParams struct { type StoreTGVersionParams struct {
Submitter *int32 `json:"submitter"` Submitter *int32 `json:"submitter"`
SystemID int32 `json:"system_id"` SystemID int32 `json:"systemId"`
TGID int32 `json:"tgid"` TGID int32 `json:"tgid"`
} }
@ -135,16 +135,16 @@ type UpsertTalkgroupBatchResults struct {
} }
type UpsertTalkgroupParams struct { type UpsertTalkgroupParams struct {
SystemID int32 `json:"system_id"` SystemID int32 `json:"systemId"`
TGID int32 `json:"tgid"` TGID int32 `json:"tgid"`
Name *string `json:"name"` Name *string `json:"name"`
AlphaTag *string `json:"alpha_tag"` AlphaTag *string `json:"alphaTag"`
TGGroup *string `json:"tg_group"` TGGroup *string `json:"tgGroup"`
Frequency *int32 `json:"frequency"` Frequency *int32 `json:"frequency"`
Metadata jsontypes.Metadata `json:"metadata"` Metadata jsontypes.Metadata `json:"metadata"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Alert interface{} `json:"alert"` Alert interface{} `json:"alert"`
AlertRules rules.AlertRules `json:"alert_rules"` AlertRules rules.AlertRules `json:"alertRules"`
Weight pgtype.Numeric `json:"weight"` Weight pgtype.Numeric `json:"weight"`
Learned *bool `json:"learned"` Learned *bool `json:"learned"`
} }

View file

@ -30,10 +30,10 @@ VALUES
type AddAlertParams struct { type AddAlertParams struct {
Time pgtype.Timestamptz `json:"time"` Time pgtype.Timestamptz `json:"time"`
TGID int `json:"tgid"` TGID int `json:"tgid"`
SystemID int `json:"system_id"` SystemID int `json:"systemId"`
Weight *float32 `json:"weight"` Weight *float32 `json:"weight"`
Score *float32 `json:"score"` Score *float32 `json:"score"`
OrigScore *float32 `json:"orig_score"` OrigScore *float32 `json:"origScore"`
Notified bool `json:"notified"` Notified bool `json:"notified"`
Metadata []byte `json:"metadata"` Metadata []byte `json:"metadata"`
} }
@ -97,18 +97,18 @@ type AddCallParams struct {
Submitter *int32 `json:"submitter"` Submitter *int32 `json:"submitter"`
System int `json:"system"` System int `json:"system"`
Talkgroup int `json:"talkgroup"` Talkgroup int `json:"talkgroup"`
CallDate pgtype.Timestamptz `json:"call_date"` CallDate pgtype.Timestamptz `json:"callDate"`
AudioName *string `json:"audio_name"` AudioName *string `json:"audioName"`
AudioBlob []byte `json:"audio_blob"` AudioBlob []byte `json:"audioBlob"`
AudioType *string `json:"audio_type"` AudioType *string `json:"audioType"`
AudioUrl *string `json:"audio_url"` AudioUrl *string `json:"audioUrl"`
Duration *int32 `json:"duration"` Duration *int32 `json:"duration"`
Frequency int `json:"frequency"` Frequency int `json:"frequency"`
Frequencies []int `json:"frequencies"` Frequencies []int `json:"frequencies"`
Patches []int `json:"patches"` Patches []int `json:"patches"`
TGLabel *string `json:"tg_label"` TGLabel *string `json:"tgLabel"`
TGAlphaTag *string `json:"tg_alpha_tag"` TGAlphaTag *string `json:"tgAlphaTag"`
TGGroup *string `json:"tg_group"` TGGroup *string `json:"tgGroup"`
Source int `json:"source"` Source int `json:"source"`
} }
@ -181,7 +181,8 @@ SELECT
tg_label, tg_label,
tg_alpha_tag, tg_alpha_tag,
tg_group, tg_group,
source source,
transcript
FROM calls FROM calls
WHERE id = $1 WHERE id = $1
` `
@ -191,18 +192,19 @@ type GetCallRow struct {
Submitter *int32 `json:"submitter"` Submitter *int32 `json:"submitter"`
System int `json:"system"` System int `json:"system"`
Talkgroup int `json:"talkgroup"` Talkgroup int `json:"talkgroup"`
CallDate pgtype.Timestamptz `json:"call_date"` CallDate pgtype.Timestamptz `json:"callDate"`
AudioName *string `json:"audio_name"` AudioName *string `json:"audioName"`
AudioType *string `json:"audio_type"` AudioType *string `json:"audioType"`
AudioUrl *string `json:"audio_url"` AudioUrl *string `json:"audioUrl"`
Duration *int32 `json:"duration"` Duration *int32 `json:"duration"`
Frequency int `json:"frequency"` Frequency int `json:"frequency"`
Frequencies []int `json:"frequencies"` Frequencies []int `json:"frequencies"`
Patches []int `json:"patches"` Patches []int `json:"patches"`
TGLabel *string `json:"tg_label"` TGLabel *string `json:"tgLabel"`
TGAlphaTag *string `json:"tg_alpha_tag"` TGAlphaTag *string `json:"tgAlphaTag"`
TGGroup *string `json:"tg_group"` TGGroup *string `json:"tgGroup"`
Source int `json:"source"` Source int `json:"source"`
Transcript *string `json:"transcript"`
} }
func (q *Queries) GetCall(ctx context.Context, id uuid.UUID) (GetCallRow, error) { func (q *Queries) GetCall(ctx context.Context, id uuid.UUID) (GetCallRow, error) {
@ -225,6 +227,7 @@ func (q *Queries) GetCall(ctx context.Context, id uuid.UUID) (GetCallRow, error)
&i.TGAlphaTag, &i.TGAlphaTag,
&i.TGGroup, &i.TGGroup,
&i.Source, &i.Source,
&i.Transcript,
) )
return i, err return i, err
} }
@ -248,10 +251,10 @@ WHERE sc.id = $1
` `
type GetCallAudioByIDRow struct { type GetCallAudioByIDRow struct {
CallDate pgtype.Timestamptz `json:"call_date"` CallDate pgtype.Timestamptz `json:"callDate"`
AudioName *string `json:"audio_name"` AudioName *string `json:"audioName"`
AudioType *string `json:"audio_type"` AudioType *string `json:"audioType"`
AudioBlob []byte `json:"audio_blob"` AudioBlob []byte `json:"audioBlob"`
} }
func (q *Queries) GetCallAudioByID(ctx context.Context, id uuid.UUID) (GetCallAudioByIDRow, error) { func (q *Queries) GetCallAudioByID(ctx context.Context, id uuid.UUID) (GetCallAudioByIDRow, error) {
@ -315,10 +318,10 @@ CASE WHEN $4::TEXT[] IS NOT NULL THEN
type ListCallsCountParams struct { type ListCallsCountParams struct {
Start pgtype.Timestamptz `json:"start"` Start pgtype.Timestamptz `json:"start"`
End pgtype.Timestamptz `json:"end"` End pgtype.Timestamptz `json:"end"`
TagsAny []string `json:"tags_any"` TagsAny []string `json:"tagsAny"`
TagsNot []string `json:"tags_not"` TagsNot []string `json:"tagsNot"`
TGFilter *string `json:"tg_filter"` TGFilter *string `json:"tgFilter"`
LongerThan pgtype.Numeric `json:"longer_than"` LongerThan pgtype.Numeric `json:"longerThan"`
} }
func (q *Queries) ListCallsCount(ctx context.Context, arg ListCallsCountParams) (int64, error) { func (q *Queries) ListCallsCount(ctx context.Context, arg ListCallsCountParams) (int64, error) {
@ -375,20 +378,20 @@ FETCH NEXT $9 ROWS ONLY
type ListCallsPParams struct { type ListCallsPParams struct {
Start pgtype.Timestamptz `json:"start"` Start pgtype.Timestamptz `json:"start"`
End pgtype.Timestamptz `json:"end"` End pgtype.Timestamptz `json:"end"`
TagsAny []string `json:"tags_any"` TagsAny []string `json:"tagsAny"`
TagsNot []string `json:"tags_not"` TagsNot []string `json:"tagsNot"`
TGFilter *string `json:"tg_filter"` TGFilter *string `json:"tgFilter"`
LongerThan pgtype.Numeric `json:"longer_than"` LongerThan pgtype.Numeric `json:"longerThan"`
Direction string `json:"direction"` Direction string `json:"direction"`
Offset int32 `json:"offset"` Offset int32 `json:"offset"`
PerPage int32 `json:"per_page"` PerPage int32 `json:"perPage"`
} }
type ListCallsPRow struct { type ListCallsPRow struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
CallDate pgtype.Timestamptz `json:"call_date"` CallDate pgtype.Timestamptz `json:"callDate"`
Duration *int32 `json:"duration"` Duration *int32 `json:"duration"`
SystemID int `json:"system_id"` SystemID int `json:"systemId"`
TGID int `json:"tgid"` TGID int `json:"tgid"`
Incidents int64 `json:"incidents"` Incidents int64 `json:"incidents"`
} }

View file

@ -61,6 +61,7 @@ INSERT INTO incidents (
name, name,
owner, owner,
description, description,
created_at,
start_time, start_time,
end_time, end_time,
location, location,
@ -70,12 +71,13 @@ INSERT INTO incidents (
$2, $2,
$3, $3,
$4, $4,
NOW(),
$5, $5,
$6, $6,
$7, $7,
$8 $8
) )
RETURNING id, name, owner, description, start_time, end_time, location, metadata RETURNING id, name, owner, description, created_at, start_time, end_time, location, metadata
` `
type CreateIncidentParams struct { type CreateIncidentParams struct {
@ -83,8 +85,8 @@ type CreateIncidentParams struct {
Name string `json:"name"` Name string `json:"name"`
Owner int `json:"owner"` Owner int `json:"owner"`
Description *string `json:"description"` Description *string `json:"description"`
StartTime pgtype.Timestamptz `json:"start_time"` StartTime pgtype.Timestamptz `json:"startTime"`
EndTime pgtype.Timestamptz `json:"end_time"` EndTime pgtype.Timestamptz `json:"endTime"`
Location []byte `json:"location"` Location []byte `json:"location"`
Metadata jsontypes.Metadata `json:"metadata"` Metadata jsontypes.Metadata `json:"metadata"`
} }
@ -106,6 +108,7 @@ func (q *Queries) CreateIncident(ctx context.Context, arg CreateIncidentParams)
&i.Name, &i.Name,
&i.Owner, &i.Owner,
&i.Description, &i.Description,
&i.CreatedAt,
&i.StartTime, &i.StartTime,
&i.EndTime, &i.EndTime,
&i.Location, &i.Location,
@ -129,6 +132,7 @@ SELECT
i.name, i.name,
i.owner, i.owner,
i.description, i.description,
i.created_at,
i.start_time, i.start_time,
i.end_time, i.end_time,
i.location, i.location,
@ -145,6 +149,7 @@ func (q *Queries) GetIncident(ctx context.Context, id uuid.UUID) (Incident, erro
&i.Name, &i.Name,
&i.Owner, &i.Owner,
&i.Description, &i.Description,
&i.CreatedAt,
&i.StartTime, &i.StartTime,
&i.EndTime, &i.EndTime,
&i.Location, &i.Location,
@ -206,16 +211,16 @@ ORDER BY ic.call_date ASC
` `
type GetIncidentCallsRow struct { type GetIncidentCallsRow struct {
CallID uuid.UUID `json:"call_id"` CallID uuid.UUID `json:"callId"`
CallDate pgtype.Timestamptz `json:"call_date"` CallDate pgtype.Timestamptz `json:"callDate"`
Duration *int32 `json:"duration"` Duration *int32 `json:"duration"`
SystemID int `json:"system_id"` SystemID int `json:"systemId"`
TGID int `json:"tgid"` TGID int `json:"tgid"`
Notes []byte `json:"notes"` Notes []byte `json:"notes"`
Submitter *int32 `json:"submitter"` Submitter *int32 `json:"submitter"`
AudioName *string `json:"audio_name"` AudioName *string `json:"audioName"`
AudioType *string `json:"audio_type"` AudioType *string `json:"audioType"`
AudioUrl *string `json:"audio_url"` AudioUrl *string `json:"audioUrl"`
Frequency int `json:"frequency"` Frequency int `json:"frequency"`
Frequencies []int `json:"frequencies"` Frequencies []int `json:"frequencies"`
Patches []int `json:"patches"` Patches []int `json:"patches"`
@ -297,6 +302,7 @@ SELECT
i.name, i.name,
i.owner, i.owner,
i.description, i.description,
i.created_at,
i.start_time, i.start_time,
i.end_time, i.end_time,
i.location, i.location,
@ -327,7 +333,7 @@ type ListIncidentsPParams struct {
Filter *string `json:"filter"` Filter *string `json:"filter"`
Direction string `json:"direction"` Direction string `json:"direction"`
Offset int32 `json:"offset"` Offset int32 `json:"offset"`
PerPage int32 `json:"per_page"` PerPage int32 `json:"perPage"`
} }
type ListIncidentsPRow struct { type ListIncidentsPRow struct {
@ -335,11 +341,12 @@ type ListIncidentsPRow struct {
Name string `json:"name"` Name string `json:"name"`
Owner int `json:"owner"` Owner int `json:"owner"`
Description *string `json:"description"` Description *string `json:"description"`
StartTime pgtype.Timestamptz `json:"start_time"` CreatedAt pgtype.Timestamptz `json:"createdAt"`
EndTime pgtype.Timestamptz `json:"end_time"` StartTime pgtype.Timestamptz `json:"startTime"`
EndTime pgtype.Timestamptz `json:"endTime"`
Location []byte `json:"location"` Location []byte `json:"location"`
Metadata jsontypes.Metadata `json:"metadata"` Metadata jsontypes.Metadata `json:"metadata"`
CallsCount int64 `json:"calls_count"` CallsCount int64 `json:"callsCount"`
} }
func (q *Queries) ListIncidentsP(ctx context.Context, arg ListIncidentsPParams) ([]ListIncidentsPRow, error) { func (q *Queries) ListIncidentsP(ctx context.Context, arg ListIncidentsPParams) ([]ListIncidentsPRow, error) {
@ -363,6 +370,7 @@ func (q *Queries) ListIncidentsP(ctx context.Context, arg ListIncidentsPParams)
&i.Name, &i.Name,
&i.Owner, &i.Owner,
&i.Description, &i.Description,
&i.CreatedAt,
&i.StartTime, &i.StartTime,
&i.EndTime, &i.EndTime,
&i.Location, &i.Location,
@ -411,14 +419,14 @@ SET
metadata = COALESCE($6, metadata) metadata = COALESCE($6, metadata)
WHERE WHERE
id = $7 id = $7
RETURNING id, name, owner, description, start_time, end_time, location, metadata RETURNING id, name, owner, description, created_at, start_time, end_time, location, metadata
` `
type UpdateIncidentParams struct { type UpdateIncidentParams struct {
Name *string `json:"name"` Name *string `json:"name"`
Description *string `json:"description"` Description *string `json:"description"`
StartTime pgtype.Timestamptz `json:"start_time"` StartTime pgtype.Timestamptz `json:"startTime"`
EndTime pgtype.Timestamptz `json:"end_time"` EndTime pgtype.Timestamptz `json:"endTime"`
Location []byte `json:"location"` Location []byte `json:"location"`
Metadata jsontypes.Metadata `json:"metadata"` Metadata jsontypes.Metadata `json:"metadata"`
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
@ -440,6 +448,7 @@ func (q *Queries) UpdateIncident(ctx context.Context, arg UpdateIncidentParams)
&i.Name, &i.Name,
&i.Owner, &i.Owner,
&i.Description, &i.Description,
&i.CreatedAt,
&i.StartTime, &i.StartTime,
&i.EndTime, &i.EndTime,
&i.Location, &i.Location,

View file

@ -1924,6 +1924,122 @@ func (_c *Store_GetShare_Call) RunAndReturn(run func(context.Context, string) (d
return _c return _c
} }
// GetSharesP provides a mock function with given fields: ctx, arg
func (_m *Store) GetSharesP(ctx context.Context, arg database.GetSharesPParams) ([]database.GetSharesPRow, error) {
ret := _m.Called(ctx, arg)
if len(ret) == 0 {
panic("no return value specified for GetSharesP")
}
var r0 []database.GetSharesPRow
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, database.GetSharesPParams) ([]database.GetSharesPRow, error)); ok {
return rf(ctx, arg)
}
if rf, ok := ret.Get(0).(func(context.Context, database.GetSharesPParams) []database.GetSharesPRow); ok {
r0 = rf(ctx, arg)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]database.GetSharesPRow)
}
}
if rf, ok := ret.Get(1).(func(context.Context, database.GetSharesPParams) error); ok {
r1 = rf(ctx, arg)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Store_GetSharesP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSharesP'
type Store_GetSharesP_Call struct {
*mock.Call
}
// GetSharesP is a helper method to define mock.On call
// - ctx context.Context
// - arg database.GetSharesPParams
func (_e *Store_Expecter) GetSharesP(ctx interface{}, arg interface{}) *Store_GetSharesP_Call {
return &Store_GetSharesP_Call{Call: _e.mock.On("GetSharesP", ctx, arg)}
}
func (_c *Store_GetSharesP_Call) Run(run func(ctx context.Context, arg database.GetSharesPParams)) *Store_GetSharesP_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(database.GetSharesPParams))
})
return _c
}
func (_c *Store_GetSharesP_Call) Return(_a0 []database.GetSharesPRow, _a1 error) *Store_GetSharesP_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *Store_GetSharesP_Call) RunAndReturn(run func(context.Context, database.GetSharesPParams) ([]database.GetSharesPRow, error)) *Store_GetSharesP_Call {
_c.Call.Return(run)
return _c
}
// GetSharesPCount provides a mock function with given fields: ctx, owner
func (_m *Store) GetSharesPCount(ctx context.Context, owner *int32) (int64, error) {
ret := _m.Called(ctx, owner)
if len(ret) == 0 {
panic("no return value specified for GetSharesPCount")
}
var r0 int64
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *int32) (int64, error)); ok {
return rf(ctx, owner)
}
if rf, ok := ret.Get(0).(func(context.Context, *int32) int64); ok {
r0 = rf(ctx, owner)
} else {
r0 = ret.Get(0).(int64)
}
if rf, ok := ret.Get(1).(func(context.Context, *int32) error); ok {
r1 = rf(ctx, owner)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Store_GetSharesPCount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSharesPCount'
type Store_GetSharesPCount_Call struct {
*mock.Call
}
// GetSharesPCount is a helper method to define mock.On call
// - ctx context.Context
// - owner *int32
func (_e *Store_Expecter) GetSharesPCount(ctx interface{}, owner interface{}) *Store_GetSharesPCount_Call {
return &Store_GetSharesPCount_Call{Call: _e.mock.On("GetSharesPCount", ctx, owner)}
}
func (_c *Store_GetSharesPCount_Call) Run(run func(ctx context.Context, owner *int32)) *Store_GetSharesPCount_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(*int32))
})
return _c
}
func (_c *Store_GetSharesPCount_Call) Return(_a0 int64, _a1 error) *Store_GetSharesPCount_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *Store_GetSharesPCount_Call) RunAndReturn(run func(context.Context, *int32) (int64, error)) *Store_GetSharesPCount_Call {
_c.Call.Return(run)
return _c
}
// GetSystemName provides a mock function with given fields: ctx, systemID // GetSystemName provides a mock function with given fields: ctx, systemID
func (_m *Store) GetSystemName(ctx context.Context, systemID int) (string, error) { func (_m *Store) GetSystemName(ctx context.Context, systemID int) (string, error) {
ret := _m.Called(ctx, systemID) ret := _m.Called(ctx, systemID)

View file

@ -17,10 +17,10 @@ type Alert struct {
ID int `json:"id,omitempty"` ID int `json:"id,omitempty"`
Time pgtype.Timestamptz `json:"time,omitempty"` Time pgtype.Timestamptz `json:"time,omitempty"`
TGID int `json:"tgid,omitempty"` TGID int `json:"tgid,omitempty"`
SystemID int `json:"system_id,omitempty"` SystemID int `json:"systemId,omitempty"`
Weight *float32 `json:"weight,omitempty"` Weight *float32 `json:"weight,omitempty"`
Score *float32 `json:"score,omitempty"` Score *float32 `json:"score,omitempty"`
OrigScore *float32 `json:"orig_score,omitempty"` OrigScore *float32 `json:"origScore,omitempty"`
Notified bool `json:"notified,omitempty"` Notified bool `json:"notified,omitempty"`
Metadata []byte `json:"metadata,omitempty"` Metadata []byte `json:"metadata,omitempty"`
} }
@ -28,10 +28,10 @@ type Alert struct {
type ApiKey struct { type ApiKey struct {
ID int `json:"id,omitempty"` ID int `json:"id,omitempty"`
Owner int `json:"owner,omitempty"` Owner int `json:"owner,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"` CreatedAt time.Time `json:"createdAt,omitempty"`
Expires pgtype.Timestamp `json:"expires,omitempty"` Expires pgtype.Timestamp `json:"expires,omitempty"`
Disabled *bool `json:"disabled,omitempty"` Disabled *bool `json:"disabled,omitempty"`
ApiKey string `json:"api_key,omitempty"` ApiKey string `json:"apiKey,omitempty"`
} }
type Call struct { type Call struct {
@ -39,18 +39,18 @@ type Call struct {
Submitter *int32 `json:"submitter,omitempty"` Submitter *int32 `json:"submitter,omitempty"`
System int `json:"system,omitempty"` System int `json:"system,omitempty"`
Talkgroup int `json:"talkgroup,omitempty"` Talkgroup int `json:"talkgroup,omitempty"`
CallDate pgtype.Timestamptz `json:"call_date,omitempty"` CallDate pgtype.Timestamptz `json:"callDate,omitempty"`
AudioName *string `json:"audio_name,omitempty"` AudioName *string `json:"audioName,omitempty"`
AudioBlob []byte `json:"audio_blob,omitempty"` AudioBlob []byte `json:"audioBlob,omitempty"`
Duration *int32 `json:"duration,omitempty"` Duration *int32 `json:"duration,omitempty"`
AudioType *string `json:"audio_type,omitempty"` AudioType *string `json:"audioType,omitempty"`
AudioUrl *string `json:"audio_url,omitempty"` AudioUrl *string `json:"audioUrl,omitempty"`
Frequency int `json:"frequency,omitempty"` Frequency int `json:"frequency,omitempty"`
Frequencies []int `json:"frequencies,omitempty"` Frequencies []int `json:"frequencies,omitempty"`
Patches []int `json:"patches,omitempty"` Patches []int `json:"patches,omitempty"`
TGLabel *string `json:"tg_label,omitempty"` TGLabel *string `json:"tgLabel,omitempty"`
TGAlphaTag *string `json:"tg_alpha_tag,omitempty"` TGAlphaTag *string `json:"tgAlphaTag,omitempty"`
TGGroup *string `json:"tg_group,omitempty"` TGGroup *string `json:"tgGroup,omitempty"`
Source int `json:"source,omitempty"` Source int `json:"source,omitempty"`
Transcript *string `json:"transcript,omitempty"` Transcript *string `json:"transcript,omitempty"`
} }
@ -60,32 +60,33 @@ type Incident struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Owner int `json:"owner,omitempty"` Owner int `json:"owner,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
StartTime pgtype.Timestamptz `json:"start_time,omitempty"` CreatedAt pgtype.Timestamptz `json:"createdAt,omitempty"`
EndTime pgtype.Timestamptz `json:"end_time,omitempty"` StartTime pgtype.Timestamptz `json:"startTime,omitempty"`
EndTime pgtype.Timestamptz `json:"endTime,omitempty"`
Location []byte `json:"location,omitempty"` Location []byte `json:"location,omitempty"`
Metadata jsontypes.Metadata `json:"metadata,omitempty"` Metadata jsontypes.Metadata `json:"metadata,omitempty"`
} }
type IncidentsCall struct { type IncidentsCall struct {
IncidentID uuid.UUID `json:"incident_id,omitempty"` IncidentID uuid.UUID `json:"incidentId,omitempty"`
CallID uuid.UUID `json:"call_id,omitempty"` CallID uuid.UUID `json:"callId,omitempty"`
CallsTblID pgtype.UUID `json:"calls_tbl_id,omitempty"` CallsTblID pgtype.UUID `json:"callsTblId,omitempty"`
SweptCallID pgtype.UUID `json:"swept_call_id,omitempty"` SweptCallID pgtype.UUID `json:"sweptCallId,omitempty"`
CallDate pgtype.Timestamptz `json:"call_date,omitempty"` CallDate pgtype.Timestamptz `json:"callDate,omitempty"`
Notes []byte `json:"notes,omitempty"` Notes []byte `json:"notes,omitempty"`
} }
type Setting struct { type Setting struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
UpdatedBy *int32 `json:"updated_by,omitempty"` UpdatedBy *int32 `json:"updatedBy,omitempty"`
Value []byte `json:"value,omitempty"` Value []byte `json:"value,omitempty"`
} }
type Share struct { type Share struct {
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`
EntityType string `json:"entity_type,omitempty"` EntityType string `json:"entityType,omitempty"`
EntityID uuid.UUID `json:"entity_id,omitempty"` EntityID uuid.UUID `json:"entityId,omitempty"`
EntityDate pgtype.Timestamptz `json:"entity_date,omitempty"` EntityDate pgtype.Timestamptz `json:"entityDate,omitempty"`
Owner int `json:"owner,omitempty"` Owner int `json:"owner,omitempty"`
Expiration pgtype.Timestamptz `json:"expiration,omitempty"` Expiration pgtype.Timestamptz `json:"expiration,omitempty"`
} }
@ -95,18 +96,18 @@ type SweptCall struct {
Submitter *int32 `json:"submitter,omitempty"` Submitter *int32 `json:"submitter,omitempty"`
System int `json:"system,omitempty"` System int `json:"system,omitempty"`
Talkgroup int `json:"talkgroup,omitempty"` Talkgroup int `json:"talkgroup,omitempty"`
CallDate pgtype.Timestamptz `json:"call_date,omitempty"` CallDate pgtype.Timestamptz `json:"callDate,omitempty"`
AudioName *string `json:"audio_name,omitempty"` AudioName *string `json:"audioName,omitempty"`
AudioBlob []byte `json:"audio_blob,omitempty"` AudioBlob []byte `json:"audioBlob,omitempty"`
Duration *int32 `json:"duration,omitempty"` Duration *int32 `json:"duration,omitempty"`
AudioType *string `json:"audio_type,omitempty"` AudioType *string `json:"audioType,omitempty"`
AudioUrl *string `json:"audio_url,omitempty"` AudioUrl *string `json:"audioUrl,omitempty"`
Frequency int `json:"frequency,omitempty"` Frequency int `json:"frequency,omitempty"`
Frequencies []int `json:"frequencies,omitempty"` Frequencies []int `json:"frequencies,omitempty"`
Patches []int `json:"patches,omitempty"` Patches []int `json:"patches,omitempty"`
TGLabel *string `json:"tg_label,omitempty"` TGLabel *string `json:"tgLabel,omitempty"`
TGAlphaTag *string `json:"tg_alpha_tag,omitempty"` TGAlphaTag *string `json:"tgAlphaTag,omitempty"`
TGGroup *string `json:"tg_group,omitempty"` TGGroup *string `json:"tgGroup,omitempty"`
Source int `json:"source,omitempty"` Source int `json:"source,omitempty"`
Transcript *string `json:"transcript,omitempty"` Transcript *string `json:"transcript,omitempty"`
} }
@ -118,16 +119,16 @@ type System struct {
type Talkgroup struct { type Talkgroup struct {
ID int `json:"id,omitempty"` ID int `json:"id,omitempty"`
SystemID int32 `json:"system_id,omitempty"` SystemID int32 `json:"systemId,omitempty"`
TGID int32 `json:"tgid,omitempty"` TGID int32 `json:"tgid,omitempty"`
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
AlphaTag *string `json:"alpha_tag,omitempty"` AlphaTag *string `json:"alphaTag,omitempty"`
TGGroup *string `json:"tg_group,omitempty"` TGGroup *string `json:"tgGroup,omitempty"`
Frequency *int32 `json:"frequency,omitempty"` Frequency *int32 `json:"frequency,omitempty"`
Metadata jsontypes.Metadata `json:"metadata,omitempty"` Metadata jsontypes.Metadata `json:"metadata,omitempty"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
Alert bool `json:"alert,omitempty"` Alert bool `json:"alert,omitempty"`
AlertRules rules.AlertRules `json:"alert_rules,omitempty"` AlertRules rules.AlertRules `json:"alertRules,omitempty"`
Weight float32 `json:"weight,omitempty"` Weight float32 `json:"weight,omitempty"`
Learned bool `json:"learned,omitempty"` Learned bool `json:"learned,omitempty"`
Ignored bool `json:"ignored,omitempty"` Ignored bool `json:"ignored,omitempty"`
@ -136,18 +137,18 @@ type Talkgroup struct {
type TalkgroupVersion struct { type TalkgroupVersion struct {
ID int `json:"id,omitempty"` ID int `json:"id,omitempty"`
Time pgtype.Timestamptz `json:"time,omitempty"` Time pgtype.Timestamptz `json:"time,omitempty"`
CreatedBy *int32 `json:"created_by,omitempty"` CreatedBy *int32 `json:"createdBy,omitempty"`
Deleted *bool `json:"deleted,omitempty"` Deleted *bool `json:"deleted,omitempty"`
SystemID *int32 `json:"system_id,omitempty"` SystemID *int32 `json:"systemId,omitempty"`
TGID *int32 `json:"tgid,omitempty"` TGID *int32 `json:"tgid,omitempty"`
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
AlphaTag *string `json:"alpha_tag,omitempty"` AlphaTag *string `json:"alphaTag,omitempty"`
TGGroup *string `json:"tg_group,omitempty"` TGGroup *string `json:"tgGroup,omitempty"`
Frequency *int32 `json:"frequency,omitempty"` Frequency *int32 `json:"frequency,omitempty"`
Metadata []byte `json:"metadata,omitempty"` Metadata []byte `json:"metadata,omitempty"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
Alert *bool `json:"alert,omitempty"` Alert *bool `json:"alert,omitempty"`
AlertRules []byte `json:"alert_rules,omitempty"` AlertRules []byte `json:"alertRules,omitempty"`
Weight *float32 `json:"weight,omitempty"` Weight *float32 `json:"weight,omitempty"`
Learned *bool `json:"learned,omitempty"` Learned *bool `json:"learned,omitempty"`
Ignored *bool `json:"ignored,omitempty"` Ignored *bool `json:"ignored,omitempty"`
@ -158,6 +159,6 @@ type User struct {
Username string `json:"username,omitempty"` Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"` Password string `json:"password,omitempty"`
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
IsAdmin bool `json:"is_admin,omitempty"` IsAdmin bool `json:"isAdmin,omitempty"`
Prefs []byte `json:"prefs,omitempty"` Prefs []byte `json:"prefs,omitempty"`
} }

View file

@ -42,6 +42,8 @@ type Querier interface {
GetIncidentOwner(ctx context.Context, id uuid.UUID) (int, error) GetIncidentOwner(ctx context.Context, id uuid.UUID) (int, error)
GetIncidentTalkgroups(ctx context.Context, incidentID uuid.UUID) ([]GetIncidentTalkgroupsRow, error) GetIncidentTalkgroups(ctx context.Context, incidentID uuid.UUID) ([]GetIncidentTalkgroupsRow, error)
GetShare(ctx context.Context, id string) (Share, error) GetShare(ctx context.Context, id string) (Share, error)
GetSharesP(ctx context.Context, arg GetSharesPParams) ([]GetSharesPRow, error)
GetSharesPCount(ctx context.Context, owner *int32) (int64, error)
GetSystemName(ctx context.Context, systemID int) (string, error) GetSystemName(ctx context.Context, systemID int) (string, error)
GetTalkgroup(ctx context.Context, systemID int32, tGID int32) (GetTalkgroupRow, error) GetTalkgroup(ctx context.Context, systemID int32, tGID int32) (GetTalkgroupRow, error)
GetTalkgroupIDsByTags(ctx context.Context, anyTags []string, allTags []string, notTags []string) ([]GetTalkgroupIDsByTagsRow, error) GetTalkgroupIDsByTags(ctx context.Context, anyTags []string, allTags []string, notTags []string) ([]GetTalkgroupIDsByTagsRow, error)

View file

@ -25,9 +25,9 @@ INSERT INTO shares (
type CreateShareParams struct { type CreateShareParams struct {
ID string `json:"id"` ID string `json:"id"`
EntityType string `json:"entity_type"` EntityType string `json:"entityType"`
EntityID uuid.UUID `json:"entity_id"` EntityID uuid.UUID `json:"entityId"`
EntityDate pgtype.Timestamptz `json:"entity_date"` EntityDate pgtype.Timestamptz `json:"entityDate"`
Owner int `json:"owner"` Owner int `json:"owner"`
Expiration pgtype.Timestamptz `json:"expiration"` Expiration pgtype.Timestamptz `json:"expiration"`
} }
@ -55,14 +55,14 @@ func (q *Queries) DeleteShare(ctx context.Context, id string) error {
const getShare = `-- name: GetShare :one const getShare = `-- name: GetShare :one
SELECT SELECT
id, s.id,
entity_type, s.entity_type,
entity_id, s.entity_id,
entity_date, s.entity_date,
owner, s.owner,
expiration s.expiration
FROM shares FROM shares s
WHERE id = $1 WHERE s.id = $1
` `
func (q *Queries) GetShare(ctx context.Context, id string) (Share, error) { func (q *Queries) GetShare(ctx context.Context, id string) (Share, error) {
@ -79,6 +79,82 @@ func (q *Queries) GetShare(ctx context.Context, id string) (Share, error) {
return i, err return i, err
} }
const getSharesP = `-- name: GetSharesP :many
SELECT
s.id, s.entity_type, s.entity_id, s.entity_date, s.owner, s.expiration,
u.username
FROM shares s
JOIN users u ON (s.owner = u.id)
WHERE
CASE WHEN $1::INTEGER IS NOT NULL THEN
s.owner = $1 ELSE TRUE END
ORDER BY
CASE WHEN $2::TEXT = 'asc' THEN s.entity_date END ASC,
CASE WHEN $2::TEXT = 'desc' THEN s.entity_date END DESC
OFFSET $3 ROWS
FETCH NEXT $4 ROWS ONLY
`
type GetSharesPParams struct {
Owner *int32 `json:"owner"`
Direction string `json:"direction"`
Offset int32 `json:"offset"`
PerPage int32 `json:"perPage"`
}
type GetSharesPRow struct {
Share Share `json:"share"`
Username string `json:"username"`
}
func (q *Queries) GetSharesP(ctx context.Context, arg GetSharesPParams) ([]GetSharesPRow, error) {
rows, err := q.db.Query(ctx, getSharesP,
arg.Owner,
arg.Direction,
arg.Offset,
arg.PerPage,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSharesPRow
for rows.Next() {
var i GetSharesPRow
if err := rows.Scan(
&i.Share.ID,
&i.Share.EntityType,
&i.Share.EntityID,
&i.Share.EntityDate,
&i.Share.Owner,
&i.Share.Expiration,
&i.Username,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getSharesPCount = `-- name: GetSharesPCount :one
SELECT COUNT(*)
FROM shares s
WHERE
CASE WHEN $1::INTEGER IS NOT NULL THEN
s.owner = $1 ELSE TRUE END
`
func (q *Queries) GetSharesPCount(ctx context.Context, owner *int32) (int64, error) {
row := q.db.QueryRow(ctx, getSharesPCount, owner)
var count int64
err := row.Scan(&count)
return count, err
}
const pruneShares = `-- name: PruneShares :exec const pruneShares = `-- name: PruneShares :exec
DELETE FROM shares WHERE expiration < NOW() DELETE FROM shares WHERE expiration < NOW()
` `

View file

@ -32,11 +32,11 @@ INSERT INTO talkgroups(
` `
type AddLearnedTalkgroupParams struct { type AddLearnedTalkgroupParams struct {
SystemID int32 `json:"system_id"` SystemID int32 `json:"systemId"`
TGID int32 `json:"tgid"` TGID int32 `json:"tgid"`
Name *string `json:"name"` Name *string `json:"name"`
AlphaTag *string `json:"alpha_tag"` AlphaTag *string `json:"alphaTag"`
TGGroup *string `json:"tg_group"` TGGroup *string `json:"tgGroup"`
} }
func (q *Queries) AddLearnedTalkgroup(ctx context.Context, arg AddLearnedTalkgroupParams) (Talkgroup, error) { func (q *Queries) AddLearnedTalkgroup(ctx context.Context, arg AddLearnedTalkgroupParams) (Talkgroup, error) {
@ -202,7 +202,7 @@ AND NOT (tags @> ARRAY[$3])
` `
type GetTalkgroupIDsByTagsRow struct { type GetTalkgroupIDsByTagsRow struct {
SystemID int32 `json:"system_id"` SystemID int32 `json:"systemId"`
TGID int32 `json:"tgid"` TGID int32 `json:"tgid"`
} }
@ -511,9 +511,9 @@ FETCH NEXT $5 ROWS ONLY
type GetTalkgroupsWithLearnedBySystemPParams struct { type GetTalkgroupsWithLearnedBySystemPParams struct {
System int32 `json:"system"` System int32 `json:"system"`
Filter *string `json:"filter"` Filter *string `json:"filter"`
OrderBy string `json:"order_by"` OrderBy string `json:"orderBy"`
Offset int32 `json:"offset"` Offset int32 `json:"offset"`
PerPage int32 `json:"per_page"` PerPage int32 `json:"perPage"`
} }
type GetTalkgroupsWithLearnedBySystemPRow struct { type GetTalkgroupsWithLearnedBySystemPRow struct {
@ -611,9 +611,9 @@ FETCH NEXT $4 ROWS ONLY
type GetTalkgroupsWithLearnedPParams struct { type GetTalkgroupsWithLearnedPParams struct {
Filter *string `json:"filter"` Filter *string `json:"filter"`
OrderBy string `json:"order_by"` OrderBy string `json:"orderBy"`
Offset int32 `json:"offset"` Offset int32 `json:"offset"`
PerPage int32 `json:"per_page"` PerPage int32 `json:"perPage"`
} }
type GetTalkgroupsWithLearnedPRow struct { type GetTalkgroupsWithLearnedPRow struct {
@ -774,17 +774,17 @@ RETURNING id, system_id, tgid, name, alpha_tag, tg_group, frequency, metadata, t
type UpdateTalkgroupParams struct { type UpdateTalkgroupParams struct {
Name *string `json:"name"` Name *string `json:"name"`
AlphaTag *string `json:"alpha_tag"` AlphaTag *string `json:"alphaTag"`
TGGroup *string `json:"tg_group"` TGGroup *string `json:"tgGroup"`
Frequency *int32 `json:"frequency"` Frequency *int32 `json:"frequency"`
Metadata jsontypes.Metadata `json:"metadata"` Metadata jsontypes.Metadata `json:"metadata"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Alert *bool `json:"alert"` Alert *bool `json:"alert"`
AlertRules rules.AlertRules `json:"alert_rules"` AlertRules rules.AlertRules `json:"alertRules"`
Weight *float32 `json:"weight"` Weight *float32 `json:"weight"`
Learned *bool `json:"learned"` Learned *bool `json:"learned"`
ID *int32 `json:"id"` ID *int32 `json:"id"`
SystemID *int32 `json:"system_id"` SystemID *int32 `json:"systemId"`
TGID *int32 `json:"tgid"` TGID *int32 `json:"tgid"`
} }

View file

@ -51,7 +51,7 @@ type CreateUserParams struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
Email string `json:"email"` Email string `json:"email"`
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"isAdmin"`
} }
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
@ -108,10 +108,10 @@ WHERE api_key = $1
type GetAPIKeyRow struct { type GetAPIKeyRow struct {
ID int `json:"id"` ID int `json:"id"`
Owner int `json:"owner"` Owner int `json:"owner"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"createdAt"`
Expires pgtype.Timestamp `json:"expires"` Expires pgtype.Timestamp `json:"expires"`
Disabled *bool `json:"disabled"` Disabled *bool `json:"disabled"`
ApiKey string `json:"api_key"` ApiKey string `json:"apiKey"`
Username string `json:"username"` Username string `json:"username"`
} }

View file

@ -2,7 +2,11 @@ package incidents
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/url"
"strings"
"dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/calls" "dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/rbac/entities" "dynatron.me/x/stillbox/pkg/rbac/entities"
@ -14,19 +18,35 @@ type Incident struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
Owner users.UserID `json:"owner"` Owner users.UserID `json:"owner"`
Name string `json:"name"` Name string `json:"name"`
Description *string `json:"description"` Description *string `json:"description,omitempty"`
StartTime *jsontypes.Time `json:"startTime"` CreatedAt jsontypes.Time `json:"createdAt"`
EndTime *jsontypes.Time `json:"endTime"` StartTime *jsontypes.Time `json:"startTime,omitempty"`
Location jsontypes.Location `json:"location"` EndTime *jsontypes.Time `json:"endTime,omitempty"`
Metadata jsontypes.Metadata `json:"metadata"` Location jsontypes.Location `json:"location,omitempty"`
Metadata jsontypes.Metadata `json:"metadata,omitempty"`
Calls []IncidentCall `json:"calls"` Calls []IncidentCall `json:"calls"`
} }
func (inc *Incident) SetShareURL(bu url.URL, shareID string) {
bu.Path = fmt.Sprintf("/share/%s/call/", shareID)
for i := range inc.Calls {
if inc.Calls[i].AudioURL != nil {
continue
}
inc.Calls[i].AudioURL = common.PtrTo(bu.String() + inc.Calls[i].ID.String())
}
}
func (inc *Incident) GetResourceName() string { func (inc *Incident) GetResourceName() string {
return entities.ResourceIncident return entities.ResourceIncident
} }
func (inc *Incident) PlaylistFilename() string {
rep := strings.NewReplacer(" ", "_", "/", "_", ":", "_")
return rep.Replace(strings.ToLower(inc.Name))
}
type IncidentCall struct { type IncidentCall struct {
calls.Call calls.Call
Notes json.RawMessage `json:"notes"` Notes json.RawMessage `json:"notes,omitempty"`
} }

View file

@ -55,7 +55,7 @@ type Store interface {
// CallIn returns whether an incident is in an call // CallIn returns whether an incident is in an call
CallIn(ctx context.Context, inc uuid.UUID, call uuid.UUID) (bool, error) 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 returns the talkgroups referenced by an incident as a map, primarily for rbac use.
TGsIn(ctx context.Context, inc uuid.UUID) (talkgroups.PresenceMap, error) TGsIn(ctx context.Context, inc uuid.UUID) (talkgroups.PresenceMap, error)
} }
@ -231,6 +231,7 @@ func fromDBIncident(id uuid.UUID, d database.Incident) incidents.Incident {
Owner: users.UserID(d.Owner), Owner: users.UserID(d.Owner),
Name: d.Name, Name: d.Name,
Description: d.Description, Description: d.Description,
CreatedAt: jsontypes.Time(d.CreatedAt.Time),
StartTime: jsontypes.TimePtrFromTSTZ(d.StartTime), StartTime: jsontypes.TimePtrFromTSTZ(d.StartTime),
EndTime: jsontypes.TimePtrFromTSTZ(d.EndTime), EndTime: jsontypes.TimePtrFromTSTZ(d.EndTime),
Metadata: d.Metadata, Metadata: d.Metadata,
@ -250,6 +251,7 @@ func fromDBListInPRow(id uuid.UUID, d database.ListIncidentsPRow) Incident {
Owner: users.UserID(d.Owner), Owner: users.UserID(d.Owner),
Name: d.Name, Name: d.Name,
Description: d.Description, Description: d.Description,
CreatedAt: jsontypes.Time(d.CreatedAt.Time),
StartTime: jsontypes.TimePtrFromTSTZ(d.StartTime), StartTime: jsontypes.TimePtrFromTSTZ(d.StartTime),
EndTime: jsontypes.TimePtrFromTSTZ(d.EndTime), EndTime: jsontypes.TimePtrFromTSTZ(d.EndTime),
Metadata: d.Metadata, Metadata: d.Metadata,
@ -268,6 +270,7 @@ func fromDBCalls(d []database.GetIncidentCallsRow) []incidents.IncidentCall {
ID: v.CallID, ID: v.CallID,
AudioName: common.ZeroIfNil(v.AudioName), AudioName: common.ZeroIfNil(v.AudioName),
AudioType: common.ZeroIfNil(v.AudioType), AudioType: common.ZeroIfNil(v.AudioType),
AudioURL: v.AudioUrl,
Duration: dur, Duration: dur,
DateTime: v.CallDate.Time, DateTime: v.CallDate.Time,
Frequencies: v.Frequencies, Frequencies: v.Frequencies,

View file

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.33.0 // protoc-gen-go v1.33.0
// protoc v5.28.3 // protoc v5.29.3
// source: stillbox.proto // source: stillbox.proto
package pb package pb

View file

@ -2,6 +2,8 @@ package entities
import ( import (
"context" "context"
"fmt"
"net/http"
"github.com/el-mike/restrict/v2" "github.com/el-mike/restrict/v2"
) )
@ -30,14 +32,15 @@ const (
func SubjectFrom(ctx context.Context) Subject { func SubjectFrom(ctx context.Context) Subject {
sub, ok := ctx.Value(SubjectCtxKey).(Subject) sub, ok := ctx.Value(SubjectCtxKey).(Subject)
if ok { if !ok {
return sub panic("no subject in context")
} }
return new(PublicSubject) return sub
} }
type Subject interface { type Subject interface {
fmt.Stringer
restrict.Subject restrict.Subject
GetName() string GetName() string
} }
@ -62,10 +65,18 @@ func (s *PublicSubject) GetName() string {
return "PUBLIC:" + s.RemoteAddr return "PUBLIC:" + s.RemoteAddr
} }
func (s *PublicSubject) String() string {
return s.GetName()
}
func (s *PublicSubject) GetRoles() []string { func (s *PublicSubject) GetRoles() []string {
return []string{RolePublic} return []string{RolePublic}
} }
func NewPublicSubject(r *http.Request) *PublicSubject {
return &PublicSubject{RemoteAddr: r.RemoteAddr}
}
type SystemServiceSubject struct { type SystemServiceSubject struct {
Name string Name string
} }
@ -74,6 +85,10 @@ func (s *SystemServiceSubject) GetName() string {
return "SYSTEM:" + s.Name return "SYSTEM:" + s.Name
} }
func (s *SystemServiceSubject) String() string {
return s.GetName()
}
func (s *SystemServiceSubject) GetRoles() []string { func (s *SystemServiceSubject) GetRoles() []string {
return []string{RoleSystem} return []string{RoleSystem}
} }

View file

@ -78,19 +78,29 @@ var Policy = &restrict.PolicyDefinition{
}, },
}, },
entities.RoleAdmin: { entities.RoleAdmin: {
Parents: []string{entities.RoleUser}, Description: "A superuser",
Parents: []string{entities.RoleUser},
Grants: restrict.GrantsMap{ Grants: restrict.GrantsMap{
entities.ResourceIncident: { entities.ResourceIncident: {
&restrict.Permission{Action: entities.ActionRead},
&restrict.Permission{Action: entities.ActionUpdate}, &restrict.Permission{Action: entities.ActionUpdate},
&restrict.Permission{Action: entities.ActionDelete}, &restrict.Permission{Action: entities.ActionDelete},
&restrict.Permission{Action: entities.ActionShare}, &restrict.Permission{Action: entities.ActionShare},
}, },
entities.ResourceCall: { entities.ResourceCall: {
&restrict.Permission{Action: entities.ActionRead},
&restrict.Permission{Action: entities.ActionUpdate}, &restrict.Permission{Action: entities.ActionUpdate},
&restrict.Permission{Action: entities.ActionDelete}, &restrict.Permission{Action: entities.ActionDelete},
&restrict.Permission{Action: entities.ActionShare}, &restrict.Permission{Action: entities.ActionShare},
}, },
entities.ResourceTalkgroup: { entities.ResourceTalkgroup: {
&restrict.Permission{Action: entities.ActionRead},
&restrict.Permission{Action: entities.ActionUpdate},
&restrict.Permission{Action: entities.ActionCreate},
&restrict.Permission{Action: entities.ActionDelete},
},
entities.ResourceShare: {
&restrict.Permission{Action: entities.ActionRead},
&restrict.Permission{Action: entities.ActionUpdate}, &restrict.Permission{Action: entities.ActionUpdate},
&restrict.Permission{Action: entities.ActionCreate}, &restrict.Permission{Action: entities.ActionCreate},
&restrict.Permission{Action: entities.ActionDelete}, &restrict.Permission{Action: entities.ActionDelete},
@ -98,16 +108,15 @@ var Policy = &restrict.PolicyDefinition{
}, },
}, },
entities.RoleSystem: { entities.RoleSystem: {
Parents: []string{entities.RoleSystem}, Description: "A system service",
Parents: []string{entities.RoleAdmin},
}, },
entities.RolePublic: { entities.RolePublic: {
/* Grants: restrict.GrantsMap{
Grants: restrict.GrantsMap{ entities.ResourceShare: {
entities.ResourceShare: { &restrict.Permission{Action: entities.ActionRead},
&restrict.Permission{Action: entities.ActionRead},
},
}, },
*/ },
}, },
}, },
PermissionPresets: restrict.PermissionPresets{ PermissionPresets: restrict.PermissionPresets{

View file

@ -8,17 +8,23 @@ import (
"github.com/el-mike/restrict/v2" "github.com/el-mike/restrict/v2"
"github.com/el-mike/restrict/v2/adapters" "github.com/el-mike/restrict/v2/adapters"
"github.com/rs/zerolog/log"
) )
var ( var (
ErrBadSubject = errors.New("bad subject in token") ErrBadSubject = errors.New("bad subject in token")
ErrAccessDenied = errors.New("access denied")
) )
func ErrAccessDenied(err error) *restrict.AccessDeniedError { func IsErrAccessDenied(err error) error {
if accessErr, ok := err.(*restrict.AccessDeniedError); ok { if accessErr, ok := err.(*restrict.AccessDeniedError); ok {
return accessErr return accessErr
} }
if err == ErrAccessDenied {
return err
}
return nil return nil
} }
@ -115,5 +121,19 @@ func (r *rbac) Check(ctx context.Context, res restrict.Resource, opts ...CheckOp
Context: o.context, Context: o.context,
} }
return sub, r.access.Authorize(req) authRes := r.access.Authorize(req)
if IsErrAccessDenied(authRes) != nil {
subS := ""
resS := ""
if sub != nil {
subS = sub.String()
}
if res != nil {
resS = res.GetResourceName()
}
log.Error().Str("resource", resS).Strs("actions", req.Actions).Str("subject", subS).Msg("access denied")
}
return sub, authRes
} }

View file

@ -48,16 +48,7 @@ func New(baseURL url.URL) *api {
incidents: newIncidentsAPI(&baseURL), incidents: newIncidentsAPI(&baseURL),
users: new(usersAPI), users: new(usersAPI),
} }
s.shares = newShareAPI(&baseURL, s.shares = newShareAPI(&baseURL, s.shareHandlers())
ShareHandlers{
ShareRequestCall: s.calls.shareCallRoute,
ShareRequestCallDL: s.calls.shareCallDLRoute,
ShareRequestIncident: s.incidents.getIncident,
ShareRequestIncidentM3U: s.incidents.getCallsM3U,
ShareRequestTalkgroups: s.tgs.getTGsShareRoute,
},
)
return s return s
} }
@ -188,7 +179,7 @@ func autoError(err error) render.Renderer {
} }
} }
if rbac.ErrAccessDenied(err) != nil { if rbac.IsErrAccessDenied(err) != nil {
return forbiddenErrText(err) return forbiddenErrText(err)
} }

View file

@ -1,6 +1,7 @@
package rest package rest
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"mime" "mime"
@ -11,7 +12,6 @@ import (
"dynatron.me/x/stillbox/internal/forms" "dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/pkg/calls/callstore" "dynatron.me/x/stillbox/pkg/calls/callstore"
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/shares"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/google/uuid" "github.com/google/uuid"
@ -102,7 +102,12 @@ func (ca *callsAPI) getAudio(p getAudioParams, w http.ResponseWriter, r *http.Re
_, _ = w.Write(call.AudioBlob) _, _ = w.Write(call.AudioBlob)
} }
func (ca *callsAPI) shareCallRoute(id ID, _ *shares.Share, w http.ResponseWriter, r *http.Request) { func (ca *callsAPI) getCallInfo(ctx context.Context, id ID) (SharedItem, error) {
cs := callstore.FromCtx(ctx)
return cs.Call(ctx, id.(uuid.UUID))
}
func (ca *callsAPI) shareCallRoute(id ID, w http.ResponseWriter, r *http.Request) {
p := getAudioParams{ p := getAudioParams{
CallID: common.PtrTo(id.(uuid.UUID)), CallID: common.PtrTo(id.(uuid.UUID)),
} }
@ -110,7 +115,7 @@ func (ca *callsAPI) shareCallRoute(id ID, _ *shares.Share, w http.ResponseWriter
ca.getAudio(p, w, r) ca.getAudio(p, w, r)
} }
func (ca *callsAPI) shareCallDLRoute(id ID, _ *shares.Share, w http.ResponseWriter, r *http.Request) { func (ca *callsAPI) shareCallDLRoute(id ID, w http.ResponseWriter, r *http.Request) {
p := getAudioParams{ p := getAudioParams{
CallID: common.PtrTo(id.(uuid.UUID)), CallID: common.PtrTo(id.(uuid.UUID)),
Download: common.PtrTo("download"), Download: common.PtrTo("download"),

View file

@ -2,6 +2,7 @@ package rest
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@ -12,7 +13,6 @@ import (
"dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/incidents" "dynatron.me/x/stillbox/pkg/incidents"
"dynatron.me/x/stillbox/pkg/incidents/incstore" "dynatron.me/x/stillbox/pkg/incidents/incstore"
"dynatron.me/x/stillbox/pkg/shares"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore" "dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -91,22 +91,22 @@ func (ia *incidentsAPI) createIncident(w http.ResponseWriter, r *http.Request) {
func (ia *incidentsAPI) getIncidentRoute(w http.ResponseWriter, r *http.Request) { func (ia *incidentsAPI) getIncidentRoute(w http.ResponseWriter, r *http.Request) {
id, err := idOnlyParam(w, r) id, err := idOnlyParam(w, r)
if err != nil { if err != nil {
wErr(w, r, autoError(err))
return return
} }
ia.getIncident(id, nil, w, r) e, err := ia.getIncident(r.Context(), id)
}
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.(uuid.UUID))
if err != nil { if err != nil {
wErr(w, r, autoError(err)) wErr(w, r, autoError(err))
return return
} }
respond(w, r, inc) respond(w, r, e)
}
func (ia *incidentsAPI) getIncident(ctx context.Context, id ID) (SharedItem, error) {
incs := incstore.FromCtx(ctx)
return incs.Incident(ctx, id.(uuid.UUID))
} }
func (ia *incidentsAPI) updateIncident(w http.ResponseWriter, r *http.Request) { func (ia *incidentsAPI) updateIncident(w http.ResponseWriter, r *http.Request) {
@ -195,10 +195,10 @@ func (ia *incidentsAPI) getCallsM3URoute(w http.ResponseWriter, r *http.Request)
return return
} }
ia.getCallsM3U(id, nil, w, r) ia.getCallsM3U(id, w, r)
} }
func (ia *incidentsAPI) getCallsM3U(id ID, share *shares.Share, w http.ResponseWriter, r *http.Request) { func (ia *incidentsAPI) getCallsM3U(id ID, w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
incs := incstore.FromCtx(ctx) incs := incstore.FromCtx(ctx)
tgst := tgstore.FromCtx(ctx) tgst := tgstore.FromCtx(ctx)
@ -213,9 +213,13 @@ func (ia *incidentsAPI) getCallsM3U(id ID, share *shares.Share, w http.ResponseW
callUrl := common.PtrTo(*ia.baseURL) callUrl := common.PtrTo(*ia.baseURL)
urlRoot := "/api/call" urlRoot := "/api/call"
filename := inc.PlaylistFilename()
share := ShareFrom(ctx)
if share != nil { if share != nil {
urlRoot = fmt.Sprintf("/share/%s/call/", share.ID) urlRoot = fmt.Sprintf("/share/%s/call/", share.ID)
filename += "_" + share.ID
} }
filename += ".m3u"
b.WriteString("#EXTM3U\n\n") b.WriteString("#EXTM3U\n\n")
for _, c := range inc.Calls { for _, c := range inc.Calls {
@ -231,11 +235,12 @@ func (ia *incidentsAPI) getCallsM3U(id ID, share *shares.Share, w http.ResponseW
callUrl.Path = urlRoot + c.ID.String() callUrl.Path = urlRoot + c.ID.String()
fmt.Fprintf(b, "#EXTINF:%d,%s%s (%s)\n%s\n\n", fmt.Fprintf(b, "#EXTINF:%d,%s%s (%s @ %s)\n%s\n\n",
c.Duration.Seconds(), c.Duration.Seconds(),
tg.StringTag(true), tg.StringTag(true),
from, from,
c.DateTime.Format("15:04 01/02"), c.Duration.ColonFormat(),
c.DateTime.Format("15:04:05 01/02"),
callUrl, callUrl,
) )
} }
@ -243,6 +248,7 @@ func (ia *incidentsAPI) getCallsM3U(id ID, share *shares.Share, w http.ResponseW
// Not a lot of agreement on which MIME type to use for non-HLS m3u, // Not a lot of agreement on which MIME type to use for non-HLS m3u,
// let's hope this is good enough // let's hope this is good enough
w.Header().Set("Content-Type", "audio/x-mpegurl") w.Header().Set("Content-Type", "audio/x-mpegurl")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = b.WriteTo(w) _, _ = b.WriteTo(w)
} }

View file

@ -1,6 +1,7 @@
package rest package rest
import ( import (
"context"
"errors" "errors"
"net/http" "net/http"
"net/url" "net/url"
@ -23,25 +24,29 @@ type ShareRequestType string
const ( const (
ShareRequestCall ShareRequestType = "call" ShareRequestCall ShareRequestType = "call"
ShareRequestCallInfo ShareRequestType = "callinfo"
ShareRequestCallDL ShareRequestType = "callDL" ShareRequestCallDL ShareRequestType = "callDL"
ShareRequestIncident ShareRequestType = "incident" ShareRequestIncident ShareRequestType = "incident"
ShareRequestIncidentM3U ShareRequestType = "m3u" ShareRequestIncidentM3U ShareRequestType = "m3u"
ShareRequestTalkgroups ShareRequestType = "talkgroups" ShareRequestTalkgroups ShareRequestType = "talkgroups"
) )
func (rt ShareRequestType) IsValid() bool { // shareHandlers returns a ShareHandlers map from the api.
switch rt { func (s *api) shareHandlers() ShareHandlers {
case ShareRequestCall, ShareRequestCallDL, ShareRequestIncident, return ShareHandlers{
ShareRequestIncidentM3U, ShareRequestTalkgroups: ShareRequestCall: s.calls.shareCallRoute,
return true ShareRequestCallInfo: s.respondShareHandler(s.calls.getCallInfo),
ShareRequestCallDL: s.calls.shareCallDLRoute,
ShareRequestIncident: s.respondShareHandler(s.incidents.getIncident),
ShareRequestIncidentM3U: s.incidents.getCallsM3U,
ShareRequestTalkgroups: s.tgs.getTGsShareRoute,
} }
return false
} }
func (rt ShareRequestType) IsValidSubtype() bool { func (rt ShareRequestType) IsValid() bool {
switch rt { switch rt {
case ShareRequestCall, ShareRequestCallDL, ShareRequestTalkgroups: case ShareRequestCall, ShareRequestCallInfo, ShareRequestCallDL, ShareRequestIncident,
ShareRequestIncidentM3U, ShareRequestTalkgroups:
return true return true
} }
@ -51,13 +56,59 @@ func (rt ShareRequestType) IsValidSubtype() bool {
type ID interface { type ID interface {
} }
type HandlerFunc func(id ID, share *shares.Share, w http.ResponseWriter, r *http.Request) type ShareHandlerFunc func(id ID, w http.ResponseWriter, r *http.Request)
type ShareHandlers map[ShareRequestType]HandlerFunc type ShareHandlers map[ShareRequestType]ShareHandlerFunc
type shareAPI struct { type shareAPI struct {
baseURL *url.URL baseURL *url.URL
shnd ShareHandlers shnd ShareHandlers
} }
type EntityFunc func(ctx context.Context, id ID) (SharedItem, error)
type SharedItem interface {
SetShareURL(baseURL url.URL, shareID string)
}
type shareResponse struct {
ID ID `json:"id"`
Type shares.EntityType `json:"type"`
SharedItem SharedItem `json:"sharedItem,omitempty"`
}
func ShareFrom(ctx context.Context) *shares.Share {
if share, hasShare := entities.SubjectFrom(ctx).(*shares.Share); hasShare {
return share
}
return nil
}
func (s *api) respondShareHandler(ie EntityFunc) ShareHandlerFunc {
return func(id ID, w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
share := ShareFrom(ctx)
if share == nil {
wErr(w, r, autoError(ErrBadShare))
return
}
res, err := ie(r.Context(), id)
if err != nil {
wErr(w, r, autoError(err))
return
}
sRes := shareResponse{
ID: share.ID,
Type: share.Type,
SharedItem: res,
}
sRes.SharedItem.SetShareURL(*s.baseURL, share.ID)
respond(w, r, sRes)
}
}
func newShareAPI(baseURL *url.URL, shnd ShareHandlers) *shareAPI { func newShareAPI(baseURL *url.URL, shnd ShareHandlers) *shareAPI {
return &shareAPI{ return &shareAPI{
baseURL: baseURL, baseURL: baseURL,
@ -70,6 +121,7 @@ func (sa *shareAPI) Subrouter() http.Handler {
r.Post(`/create`, sa.createShare) r.Post(`/create`, sa.createShare)
r.Delete(`/{id:[A-Za-z0-9_-]{20,}}`, sa.deleteShare) r.Delete(`/{id:[A-Za-z0-9_-]{20,}}`, sa.deleteShare)
r.Post(`/`, sa.listShares)
return r return r
} }
@ -79,6 +131,7 @@ func (sa *shareAPI) RootRouter() http.Handler {
r.Get("/{shareId:[A-Za-z0-9_-]{20,}}", sa.routeShare) r.Get("/{shareId:[A-Za-z0-9_-]{20,}}", sa.routeShare)
r.Get("/{shareId:[A-Za-z0-9_-]{20,}}/{type}", sa.routeShare) r.Get("/{shareId:[A-Za-z0-9_-]{20,}}/{type}", 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) r.Get("/{shareId:[A-Za-z0-9_-]{20,}}/{type}/{subID}", sa.routeShare)
return r return r
} }
@ -104,6 +157,34 @@ func (sa *shareAPI) createShare(w http.ResponseWriter, r *http.Request) {
respond(w, r, sh) respond(w, r, sh)
} }
func (sa *shareAPI) listShares(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
shs := shares.FromCtx(ctx)
p := shares.SharesParams{}
err := forms.Unmarshal(r, &p, forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty())
if err != nil {
wErr(w, r, badRequest(err))
return
}
shRes, count, err := shs.Shares(ctx, p)
if err != nil {
wErr(w, r, autoError(err))
return
}
response := struct {
Shares []*shares.Share `json:"shares"`
TotalCount int `json:"totalCount"`
}{
Shares: shRes,
TotalCount: count,
}
respond(w, r, &response)
}
func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) { func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
shs := shares.FromCtx(ctx) shs := shares.FromCtx(ctx)
@ -134,12 +215,11 @@ func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) {
} else { } else {
switch sh.Type { switch sh.Type {
case shares.EntityCall: case shares.EntityCall:
rType = ShareRequestCall rType = ShareRequestCallInfo
params.SubID = common.PtrTo(sh.EntityID.String()) params.SubID = common.PtrTo(sh.EntityID.String())
case shares.EntityIncident: case shares.EntityIncident:
rType = ShareRequestIncident rType = ShareRequestIncident
} }
w.Header().Set("X-Share-Type", string(rType))
} }
if !rType.IsValid() { if !rType.IsValid() {
@ -157,23 +237,44 @@ func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) {
switch rType { switch rType {
case ShareRequestTalkgroups: case ShareRequestTalkgroups:
sa.shnd[rType](nil, sh, w, r) sa.shnd[rType](nil, w, r)
case ShareRequestCall, ShareRequestCallDL: case ShareRequestCall, ShareRequestCallInfo, ShareRequestCallDL:
if params.SubID == nil { var subIDU uuid.UUID
wErr(w, r, autoError(ErrBadShare)) if params.SubID != nil {
return subIDU, err = uuid.Parse(*params.SubID)
if err != nil {
wErr(w, r, badRequest(err))
return
}
} else {
subIDU = sh.EntityID
} }
subIDU, err := uuid.Parse(*params.SubID) sa.shnd[rType](subIDU, w, r)
if err != nil {
wErr(w, r, badRequest(err))
return
}
sa.shnd[rType](subIDU, sh, w, r)
case ShareRequestIncident, ShareRequestIncidentM3U: case ShareRequestIncident, ShareRequestIncidentM3U:
sa.shnd[rType](sh.EntityID, sh, w, r) sa.shnd[rType](sh.EntityID, w, r)
} }
} }
func (sa *shareAPI) deleteShare(w http.ResponseWriter, r *http.Request) { func (sa *shareAPI) deleteShare(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
shs := shares.FromCtx(ctx)
p := struct {
ID string `param:"id"`
}{}
err := decodeParams(&p, r)
if err != nil {
wErr(w, r, autoError(err))
return
}
err = shs.Delete(ctx, p.ID)
if err != nil {
wErr(w, r, autoError(err))
return
}
w.WriteHeader(http.StatusNoContent)
} }

View file

@ -4,11 +4,11 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"slices"
"dynatron.me/x/stillbox/internal/forms" "dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/incidents/incstore" "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"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore" "dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"dynatron.me/x/stillbox/pkg/talkgroups/xport" "dynatron.me/x/stillbox/pkg/talkgroups/xport"
@ -161,10 +161,16 @@ func (tga *talkgroupAPI) postPaginated(w http.ResponseWriter, r *http.Request) {
respond(w, r, res) respond(w, r, res)
} }
func (tga *talkgroupAPI) getTGsShareRoute(_ ID, share *shares.Share, w http.ResponseWriter, r *http.Request) { func (tga *talkgroupAPI) getTGsShareRoute(_ ID, w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
tgs := tgstore.FromCtx(ctx) tgs := tgstore.FromCtx(ctx)
share := ShareFrom(ctx)
if share == nil {
wErr(w, r, autoError(ErrBadShare))
return
}
tgIDs, err := incstore.FromCtx(ctx).TGsIn(ctx, share.EntityID) tgIDs, err := incstore.FromCtx(ctx).TGsIn(ctx, share.EntityID)
if err != nil { if err != nil {
wErr(w, r, autoError(err)) wErr(w, r, autoError(err))
@ -176,6 +182,14 @@ func (tga *talkgroupAPI) getTGsShareRoute(_ ID, share *shares.Share, w http.Resp
idSl = append(idSl, id) idSl = append(idSl, id)
} }
slices.SortFunc(idSl, func(a, b talkgroups.ID) int {
if d := int(a.System) - int(b.System); d != 0 {
return d
}
return int(a.Talkgroup) - int(b.Talkgroup)
})
tgRes, err := tgs.TGs(ctx, idSl) tgRes, err := tgs.TGs(ctx, idSl)
if err != nil { if err != nil {
wErr(w, r, autoError(err)) wErr(w, r, autoError(err))

View file

@ -29,10 +29,11 @@ func (s *Server) setupRoutes() {
r.Use(s.WithCtxStores()) r.Use(s.WithCtxStores())
s.installPprof() s.installPprof()
r.Use(s.auth.VerifyMiddleware())
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(s.auth.AuthorizedSubjectMiddleware())
// authenticated routes // authenticated routes
r.Use(s.auth.VerifyMiddleware(), s.auth.AuthMiddleware())
s.nex.PrivateRoutes(r) s.nex.PrivateRoutes(r)
s.auth.PrivateRoutes(r) s.auth.PrivateRoutes(r)
s.alerter.PrivateRoutes(r) s.alerter.PrivateRoutes(r)
@ -41,6 +42,7 @@ func (s *Server) setupRoutes() {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
s.rateLimit(r) s.rateLimit(r)
r.Use(s.auth.PublicSubjectMiddleware())
r.Use(render.SetContentType(render.ContentTypeJSON)) r.Use(render.SetContentType(render.ContentTypeJSON))
// public routes // public routes
s.sources.PublicRoutes(r) s.sources.PublicRoutes(r)
@ -49,6 +51,7 @@ func (s *Server) setupRoutes() {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
// auth/share routes get rate-limited heavily, but not using middleware // auth/share routes get rate-limited heavily, but not using middleware
s.rateLimit(r) s.rateLimit(r)
r.Use(s.auth.PublicSubjectMiddleware())
r.Use(render.SetContentType(render.ContentTypeJSON)) r.Use(render.SetContentType(render.ContentTypeJSON))
s.auth.PublicRoutes(r) s.auth.PublicRoutes(r)
r.Mount("/share", s.rest.ShareRouter()) r.Mount("/share", s.rest.ShareRouter())
@ -56,9 +59,8 @@ func (s *Server) setupRoutes() {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
s.rateLimit(r) s.rateLimit(r)
r.Use(s.auth.VerifyMiddleware())
// optional auth routes // optional auth routes
r.Use(s.auth.PublicSubjectMiddleware())
s.clientRoute(r, clientRoot) s.clientRoute(r, clientRoot)
}) })

View file

@ -18,6 +18,7 @@ func (s *Server) huppers() []hupper {
s.logger, s.logger,
s.auth, s.auth,
s.tgs, s.tgs,
s.alerter,
} }
} }

View file

@ -47,8 +47,9 @@ func (et EntityType) IsValid() bool {
type Share struct { type Share struct {
ID string `json:"id"` ID string `json:"id"`
Type EntityType `json:"entityType"` Type EntityType `json:"entityType"`
Date *jsontypes.Time `json:"-"` // we handle this for the user Date *jsontypes.Time `json:"entityDate,omitempty"` // we handle this for the user
Owner users.UserID `json:"owner"` Owner users.UserID `json:"-"`
OwnerUser *string `json:"owner,omitempty"`
EntityID uuid.UUID `json:"entityID"` EntityID uuid.UUID `json:"entityID"`
Expiration *jsontypes.Time `json:"expiration"` Expiration *jsontypes.Time `json:"expiration"`
} }
@ -57,6 +58,10 @@ func (s *Share) GetName() string {
return "SHARE:" + s.ID return "SHARE:" + s.ID
} }
func (s *Share) String() string {
return s.GetName()
}
func (s *Share) GetRoles() []string { func (s *Share) GetRoles() []string {
return []string{entities.RoleShareGuest} return []string{entities.RoleShareGuest}
} }

View file

@ -3,7 +3,9 @@ package shares
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/rbac" "dynatron.me/x/stillbox/pkg/rbac"
@ -12,13 +14,21 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
) )
type SharesParams struct {
common.Pagination
Direction *common.SortDirection `json:"dir"`
}
type Shares interface { type Shares interface {
// NewShare creates a new share. // NewShare creates a new share.
NewShare(ctx context.Context, sh CreateShareParams) (*Share, error) NewShare(ctx context.Context, sh CreateShareParams) (*Share, error)
// Share retreives a share record. // Share retrieves a share record.
GetShare(ctx context.Context, id string) (*Share, error) GetShare(ctx context.Context, id string) (*Share, error)
// Shares retrieves shares visible by the context Subject.
Shares(ctx context.Context, p SharesParams) (shares []*Share, totalCount int, err error)
// Create stores a new share record. // Create stores a new share record.
Create(ctx context.Context, share *Share) error Create(ctx context.Context, share *Share) error
@ -48,6 +58,11 @@ func recToShare(share database.Share) *Share {
} }
func (s *postgresStore) GetShare(ctx context.Context, id string) (*Share, error) { func (s *postgresStore) GetShare(ctx context.Context, id string) (*Share, error) {
_, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceShare), rbac.WithActions(entities.ActionRead))
if err != nil {
return nil, err
}
db := database.FromCtx(ctx) db := database.FromCtx(ctx)
rec, err := db.GetShare(ctx, id) rec, err := db.GetShare(ctx, id)
switch err { switch err {
@ -80,7 +95,12 @@ func (s *postgresStore) Create(ctx context.Context, share *Share) error {
} }
func (s *postgresStore) Delete(ctx context.Context, id string) error { func (s *postgresStore) Delete(ctx context.Context, id string) error {
_, err := rbac.Check(ctx, new(Share), rbac.WithActions(entities.ActionDelete)) sh, err := s.GetShare(ctx, id)
if err != nil {
return err
}
_, err = rbac.Check(ctx, sh, rbac.WithActions(entities.ActionDelete))
if err != nil { if err != nil {
return err return err
} }
@ -88,6 +108,54 @@ func (s *postgresStore) Delete(ctx context.Context, id string) error {
return database.FromCtx(ctx).DeleteShare(ctx, id) return database.FromCtx(ctx).DeleteShare(ctx, id)
} }
func (s *postgresStore) Shares(ctx context.Context, p SharesParams) (shares []*Share, totalCount int, err error) {
sub := entities.SubjectFrom(ctx)
// ersatz RBAC
owner := common.PtrTo(int32(-1)) // invalid UID
switch s := sub.(type) {
case *users.User:
if !s.IsAdmin {
owner = s.ID.Int32Ptr()
} else {
owner = nil
}
case *entities.SystemServiceSubject:
owner = nil
default:
return nil, 0, rbac.ErrAccessDenied
}
db := database.FromCtx(ctx)
count, err := db.GetSharesPCount(ctx, owner)
if err != nil {
return nil, 0, fmt.Errorf("shares count: %w", err)
}
offset, perPage := p.Pagination.OffsetPerPage(100)
dbParam := database.GetSharesPParams{
Owner: owner,
Direction: p.Direction.DirString(common.DirAsc),
Offset: offset,
PerPage: perPage,
}
shs, err := db.GetSharesP(ctx, dbParam)
if err != nil {
return nil, 0, err
}
shares = make([]*Share, 0, len(shs))
for _, v := range shs {
s := recToShare(v.Share)
s.OwnerUser = &v.Username
shares = append(shares, s)
}
return shares, int(count), nil
}
func (s *postgresStore) Prune(ctx context.Context) error { func (s *postgresStore) Prune(ctx context.Context) error {
return database.FromCtx(ctx).PruneShares(ctx) return database.FromCtx(ctx).PruneShares(ctx)
} }

View file

@ -134,7 +134,7 @@ func (h *RdioHTTP) routeCallUpload(w http.ResponseWriter, r *http.Request) {
} }
err = h.ing.Ingest(entities.CtxWithSubject(ctx, submitterSub), call) err = h.ing.Ingest(entities.CtxWithSubject(ctx, submitterSub), call)
if err != nil { if err != nil {
if rbac.ErrAccessDenied(err) != nil { if rbac.IsErrAccessDenied(err) != nil {
log.Error().Err(err).Msg("ingest failed") log.Error().Err(err).Msg("ingest failed")
http.Error(w, "Call ingest failed.", http.StatusForbidden) http.Error(w, "Call ingest failed.", http.StatusForbidden)
} }

View file

@ -588,6 +588,7 @@ func (t *cache) UpdateTG(ctx context.Context, input database.UpdateTalkgroupPara
} }
func (t *cache) DeleteSystem(ctx context.Context, id int) error { func (t *cache) DeleteSystem(ctx context.Context, id int) error {
// talkgroups don't have owners, so we can use a generic Resource
_, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionDelete)) _, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionDelete))
if err != nil { if err != nil {
return err return err

File diff suppressed because one or more lines are too long

View file

@ -2,11 +2,16 @@ package users
import ( import (
"context" "context"
"errors"
"dynatron.me/x/stillbox/internal/cache" "dynatron.me/x/stillbox/internal/cache"
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
) )
var (
ErrNoSuchUser = errors.New("no such user")
)
type Store interface { type Store interface {
// GetUser gets a user by UID. // GetUser gets a user by UID.
GetUser(ctx context.Context, username string) (*User, error) GetUser(ctx context.Context, username string) (*User, error)
@ -84,6 +89,10 @@ func (s *postgresStore) GetUser(ctx context.Context, username string) (*User, er
dbu, err := s.db.GetUserByUsername(ctx, username) dbu, err := s.db.GetUserByUsername(ctx, username)
if err != nil { if err != nil {
if database.IsNoRows(err) {
return nil, ErrNoSuchUser
}
return nil, err return nil, err
} }

View file

@ -71,6 +71,10 @@ func (u *User) GetName() string {
return u.Username return u.Username
} }
func (u *User) String() string {
return "USER:"+u.GetName()
}
func (u *User) GetRoles() []string { func (u *User) GetRoles() []string {
r := make([]string, 1, 2) r := make([]string, 1, 2)

View file

@ -143,6 +143,7 @@ CREATE TABLE IF NOT EXISTS incidents(
name TEXT NOT NULL, name TEXT NOT NULL,
owner INTEGER NOT NULL, owner INTEGER NOT NULL,
description TEXT, description TEXT,
created_at TIMESTAMPTZ,
start_time TIMESTAMPTZ, start_time TIMESTAMPTZ,
end_time TIMESTAMPTZ, end_time TIMESTAMPTZ,
location JSONB, location JSONB,

View file

@ -180,6 +180,7 @@ SELECT
tg_label, tg_label,
tg_alpha_tag, tg_alpha_tag,
tg_group, tg_group,
source source,
transcript
FROM calls FROM calls
WHERE id = @id; WHERE id = @id;

View file

@ -42,6 +42,7 @@ INSERT INTO incidents (
name, name,
owner, owner,
description, description,
created_at,
start_time, start_time,
end_time, end_time,
location, location,
@ -51,6 +52,7 @@ INSERT INTO incidents (
@name, @name,
@owner, @owner,
sqlc.narg('description'), sqlc.narg('description'),
NOW(),
sqlc.narg('start_time'), sqlc.narg('start_time'),
sqlc.narg('end_time'), sqlc.narg('end_time'),
sqlc.narg('location'), sqlc.narg('location'),
@ -65,6 +67,7 @@ SELECT
i.name, i.name,
i.owner, i.owner,
i.description, i.description,
i.created_at,
i.start_time, i.start_time,
i.end_time, i.end_time,
i.location, i.location,
@ -160,6 +163,7 @@ SELECT
i.name, i.name,
i.owner, i.owner,
i.description, i.description,
i.created_at,
i.start_time, i.start_time,
i.end_time, i.end_time,
i.location, i.location,

View file

@ -1,13 +1,13 @@
-- name: GetShare :one -- name: GetShare :one
SELECT SELECT
id, s.id,
entity_type, s.entity_type,
entity_id, s.entity_id,
entity_date, s.entity_date,
owner, s.owner,
expiration s.expiration
FROM shares FROM shares s
WHERE id = @id; WHERE s.id = @id;
-- name: CreateShare :exec -- name: CreateShare :exec
INSERT INTO shares ( INSERT INTO shares (
@ -24,3 +24,27 @@ DELETE FROM shares WHERE id = @id;
-- name: PruneShares :exec -- name: PruneShares :exec
DELETE FROM shares WHERE expiration < NOW(); DELETE FROM shares WHERE expiration < NOW();
-- name: GetSharesP :many
SELECT
sqlc.embed(s),
u.username
FROM shares s
JOIN users u ON (s.owner = u.id)
WHERE
CASE WHEN sqlc.narg('owner')::INTEGER IS NOT NULL THEN
s.owner = @owner ELSE TRUE END
ORDER BY
CASE WHEN @direction::TEXT = 'asc' THEN s.entity_date END ASC,
CASE WHEN @direction::TEXT = 'desc' THEN s.entity_date END DESC
OFFSET sqlc.arg('offset') ROWS
FETCH NEXT sqlc.arg('per_page') ROWS ONLY
;
-- name: GetSharesPCount :one
SELECT COUNT(*)
FROM shares s
WHERE
CASE WHEN sqlc.narg('owner')::INTEGER IS NOT NULL THEN
s.owner = @owner ELSE TRUE END
;

View file

@ -11,6 +11,7 @@ sql:
query_parameter_limit: 3 query_parameter_limit: 3
emit_json_tags: true emit_json_tags: true
emit_interface: true emit_interface: true
json_tags_case_style: camel
initialisms: initialisms:
- id - id
- tgid - tgid