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:

View file

@ -64,7 +64,7 @@ protobuf best practices (i.e. not changing field numbers).
## 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.

View file

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

View file

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

View file

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

View file

@ -8,6 +8,11 @@ export const routes: Routes = [
loadComponent: () =>
import('./login/login.component').then((m) => m.LoginComponent),
},
{
path: 's/:id',
loadComponent: () =>
import('./share/share.component').then((m) => m.ShareComponent),
},
{
path: '',
canActivateChild: [AuthGuard],
@ -71,6 +76,12 @@ export const routes: Routes = [
import('./alerts/alerts.component').then((m) => m.AlertsComponent),
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) => {
const router: Router = inject(Router);
const authSvc: AuthService = inject(AuthService);
if (localStorage.getItem('jwt') == null) {
if (authSvc.token() === null) {
let success = false;
authSvc.refresh().subscribe({
next: (event) => {

View file

@ -1,8 +1,9 @@
export interface CallRecord {
id: string;
call_date: Date;
callDate: Date;
audioURL: string | null;
duration: number;
system_id: number;
systemId: number;
tgid: number;
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">
<th mat-header-cell *matHeaderCellDef>Date</th>
<td mat-cell *matCellDef="let call">
{{ call.call_date | grabDate }}
{{ call.callDate | grabDate }}
</td>
</ng-container>
<ng-container matColumnDef="time">
<th mat-header-cell *matHeaderCellDef>Time</th>
<td [title]="call.call_date" mat-cell *matCellDef="let call">
{{ call.call_date | time }}
<td [title]="call.callDate" mat-cell *matCellDef="let call">
{{ call.callDate | time }}
</td>
</ng-container>
<ng-container matColumnDef="system">

View file

@ -1,10 +1,4 @@
import {
Component,
inject,
Pipe,
PipeTransform,
ViewChild,
} from '@angular/core';
import { Component, inject, ViewChild } from '@angular/core';
import { CommonModule, AsyncPipe } from '@angular/common';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTableModule } from '@angular/material/table';
@ -17,13 +11,20 @@ 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, Observable, Subscription } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { CallsListParams, CallsService } from './calls.service';
import { BehaviorSubject, Subject, Subscription } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import {
CallsListParams,
CallsService,
DatePipe,
DownloadURLPipe,
FixedPointPipe,
TalkgroupPipe,
TimePipe,
} from './calls.service';
import { CallRecord } from '../calls';
import { TalkgroupService } from '../talkgroups/talkgroups.service';
import { Talkgroup } from '../talkgroup';
import { MatFormFieldModule } from '@angular/material/form-field';
import {
FormControl,
@ -49,94 +50,6 @@ import {
import { IncidentRecord } from '../incidents';
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;
@Component({
selector: 'app-calls',
@ -144,8 +57,8 @@ const reqPageSize = 200;
MatIconModule,
FixedPointPipe,
TalkgroupPipe,
DatePipe,
TimePipe,
DatePipe,
MatPaginatorModule,
MatTableModule,
AsyncPipe,
@ -329,6 +242,7 @@ export class CallsComponent {
this.subscriptions.add(
this.fetchCalls
.pipe(
debounceTime(500),
switchMap((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 { Observable } from 'rxjs';
import { map, Observable } from 'rxjs';
import { CallRecord } from '../calls';
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 {
start: Date | null;

View file

@ -30,7 +30,11 @@ export class CallPlayerComponent {
this.playSub.unsubscribe();
});
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.play().then(null, (reason) => {
this.playing = false;

View file

@ -3,21 +3,32 @@
<div class="cardHdr">
<h1>
{{ 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
>
</h1>
<button mat-icon-button (click)="editIncident(incID)">
<mat-icon>edit</mat-icon>
</button>
<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
@if (share == null) {
<button mat-icon-button (click)="editIncident(incID)">
<mat-icon>edit</mat-icon>
</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 class="inc-heading">
<div class="field field-start field-label">Start</div>
@ -64,25 +75,25 @@
<ng-container matColumnDef="date">
<th mat-header-cell *matHeaderCellDef>Date</th>
<td mat-cell *matCellDef="let call">
{{ call.call_date | grabDate }}
{{ call.callDate | grabDate }}
</td>
</ng-container>
<ng-container matColumnDef="time">
<th mat-header-cell *matHeaderCellDef>Time</th>
<td [title]="call.call_date" mat-cell *matCellDef="let call">
{{ call.call_date | time }}
<td [title]="call.callDate" mat-cell *matCellDef="let call">
{{ call.callDate | time }}
</td>
</ng-container>
<ng-container matColumnDef="system">
<th mat-header-cell *matHeaderCellDef>System</th>
<td mat-cell *matCellDef="let call">
{{ call | talkgroup: "system" | async }}
{{ call | talkgroup: "system" : share | async }}
</td>
</ng-container>
<ng-container matColumnDef="group">
<th mat-header-cell *matHeaderCellDef>Group</th>
<td mat-cell *matCellDef="let call">
{{ call | talkgroup: "group" | async }}
{{ call | talkgroup: "group" : share | async }}
</td>
</ng-container>
<ng-container matColumnDef="talkgroup">

View file

@ -1,5 +1,5 @@
.incident {
margin: 50px 50px 50px 50px;
margin: 0px 50px 50px 50px;
padding: 50px 50px 50px 50px;
display: flex;
flex-flow: column;
@ -7,12 +7,6 @@
margin-right: auto;
}
@media not screen and (max-width: 768px) {
.incident {
width: 75%;
}
}
.inc-heading {
display: flex;
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 { CommonModule, Location } from '@angular/common';
import { BehaviorSubject, merge, Subscription } from 'rxjs';
import { BehaviorSubject, merge, Subject, Subscription } from 'rxjs';
import { Observable } from 'rxjs';
import {
ReactiveFormsModule,
@ -35,10 +35,13 @@ import {
TimePipe,
DatePipe,
DownloadURLPipe,
} from '../../calls/calls.component';
} from '../../calls/calls.service';
import { CallPlayerComponent } from '../../calls/player/call-player/call-player.component';
import { FmtDatePipe } from '../incidents.component';
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 {
incID: string;
@ -151,8 +154,9 @@ export class IncidentEditDialogComponent {
styleUrl: './incident.component.scss',
})
export class IncidentComponent {
incPrime = new BehaviorSubject<IncidentRecord>(<IncidentRecord>{});
incPrime = new Subject<IncidentRecord>();
inc$!: Observable<IncidentRecord>;
@Input() share?: Share;
subscriptions: Subscription = new Subscription();
dialog = inject(MatDialog);
incID!: string;
@ -174,15 +178,30 @@ export class IncidentComponent {
private route: ActivatedRoute,
private incSvc: IncidentsService,
private location: Location,
private tgSvc: TalkgroupService,
) {}
saveIncName(ev: Event) {}
ngOnInit() {
this.incID = this.route.snapshot.paramMap.get('id')!;
this.inc$ = merge(this.incSvc.getIncident(this.incID), this.incPrime).pipe(
if (this.share) {
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) => {
if (inc.calls) {
if (inc && 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 { Router } from '@angular/router';
import { Observable } from 'rxjs';
import { Observable, Subject } from 'rxjs';
import { tap } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export class Jwt {
constructor(public jwt: string) {}
}
type AuthState = {
user: string | null;
token: string | null;
is_auth: boolean;
};
@Injectable({
providedIn: 'root',
})
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(
private http: HttpClient,
private _router: Router,
) {
let ssJWT = localStorage.getItem('jwt');
if (ssJWT) {
this.loggedIn = true;
}
effect(() => {
const token = this.token();
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
.post<Jwt>(
'/api/login',
{ username: username, password: password },
{ observe: 'response' },
)
.pipe(
tap((event) => {
if (event.status == 200) {
localStorage.setItem('jwt', event.body?.jwt.toString() ?? '');
this.loggedIn = true;
this._router.navigateByUrl('/home');
}
}),
);
.post<Jwt>('/api/login', { username: username, password: password })
.pipe(takeUntilDestroyed(this.destroyed))
.subscribe({
next: (res) => {
let state = <AuthState>{
user: username,
token: res.jwt,
is_auth: true,
};
this._state.set(state);
this.loginFailed.update(() => false);
this._router.navigateByUrl('/');
},
error: (err) => {
this.loginFailed.update(() => true);
},
});
}
_clearState() {
this._state.set(<AuthState>{
is_auth: false,
token: null,
});
}
logout() {
this.http
.get('/api/logout', { withCredentials: true, observe: 'response' })
.subscribe((event) => {
if (event.status == 200) {
this.loggedIn = false;
}
});
localStorage.removeItem('jwt');
this.loggedIn = false;
this.http.get('/api/logout', { withCredentials: true }).subscribe({
next: (event) => {
this._clearState();
},
error: (err) => {
this._clearState();
},
});
this._router.navigateByUrl('/login');
}
@ -60,14 +99,20 @@ export class AuthService {
.pipe(
tap((event) => {
if (event.status == 200) {
localStorage.setItem('jwt', event.body?.jwt.toString() ?? '');
this.loggedIn = true;
let ost = this._state();
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 {
return localStorage.getItem('jwt');
return localStorage.getItem(this._accessTokenKey);
}
}

View file

@ -28,7 +28,7 @@
<button class="login sbButton" (click)="onSubmit()">Login</button>
</div>
</form>
@if (failed) {
@if (failed()) {
<div role="alert">
<span>Login Failed!</span>
</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 { AuthService } from '../login/auth.service';
import { catchError, of, Subscription } from 'rxjs';
@ -17,31 +17,9 @@ export class LoginComponent {
router: Router = inject(Router);
username: string = '';
password: string = '';
failed: boolean = false;
private subscriptions = new Subscription();
failed = this.apiService.loginFailed;
onSubmit() {
this.failed = false;
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();
this.apiService.login(this.username, this.password);
}
}

View file

@ -101,6 +101,11 @@ export class NavigationComponent {
url: '/alerts',
icon: 'notifications',
},
{
name: 'Shares',
url: '/shares',
icon: 'share',
},
];
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 {
id!: number;
system_id!: number;
systemId!: number;
tgid!: number;
name!: string;
alpha_tag!: string;
tg_group!: string;
alphaTag!: string;
tgGroup!: string;
frequency!: number;
metadata!: Metadata | null;
tags!: string[];
alert!: boolean;
system?: System;
alert_rules!: AlertRule[];
alertRules!: AlertRule[];
weight!: number;
learned?: boolean;
icon?: string;
iconSvg?: string;
constructor(
id: number,
system_id: number,
systemId: number,
tgid: number,
name: string,
alpha_tag: string,
tg_group: string,
alphaTag: string,
tgGroup: string,
frequency: number,
metadata: Metadata | null,
tags: string[],
alert: boolean,
alert_rules: AlertRule[],
alertRules: AlertRule[],
weight: number,
system?: System,
learned?: boolean,
icon?: string,
) {
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 {
@ -103,7 +99,7 @@ export class Talkgroup {
tgTuple(): TGID {
return <TGID>{
sys: this.system_id,
sys: this.systemId,
tg: this.tgid,
};
}
@ -115,15 +111,15 @@ export interface TalkgroupUI extends Talkgroup {
export interface TalkgroupUpdate {
id: number;
system_id: number;
systemId: number;
tgid: number;
name: string | null;
alpha_tag: string | null;
tg_group: string | null;
alphaTag: string | null;
tgGroup: string | null;
frequency: number | null;
metadata: Object | null;
tags: string[] | null;
alert: boolean | null;
alert_rules: AlertRule[] | null;
alertRules: AlertRule[] | null;
weight: number | null;
}

View file

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

View file

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

View file

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

View file

@ -34,7 +34,7 @@
</ng-container>
<ng-container matColumnDef="group">
<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 matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
@ -42,7 +42,7 @@
</ng-container>
<ng-container matColumnDef="alphaTag">
<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 matColumnDef="tgid">
<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 {
BehaviorSubject,
concatMap,
Observable,
ReplaySubject,
shareReplay,
Subject,
Subscription,
switchMap,
} from 'rxjs';
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 {
page: number;
@ -29,30 +32,57 @@ export class TalkgroupService {
private readonly _getTalkgroup = new Map<string, ReplaySubject<Talkgroup>>();
private tgs$: Observable<Talkgroup[]>;
private tags$!: Observable<string[]>;
private fetchAll = new BehaviorSubject<'fetch'>('fetch');
private fetchAll = new ReplaySubject<Share | null>();
private subscriptions = new Subscription();
constructor(private http: HttpClient) {
this.tgs$ = this.fetchAll.pipe(switchMap(() => this.getTalkgroups()));
constructor(
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(
switchMap(() => this.getAllTags()),
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();
}
setShare(share: Share | null) {
if (!this.authSvc.isAuth() && share !== null) {
this.fetchAll.next(share);
}
}
ngOnDestroy() {
this.subscriptions.unsubscribe();
}
getAllTags(): Observable<string[]> {
return this.http.get<string[]>('/api/talkgroup/tags').pipe(shareReplay());
return this.http.get<string[]>('/api/talkgroup/tags');
}
getTalkgroups(): Observable<Talkgroup[]> {
return this.http.get<Talkgroup[]>('/api/talkgroup/').pipe(shareReplay());
getTalkgroups(share: Share | null): Observable<Talkgroup[]> {
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);
if (!this._getTalkgroup.get(key)) {
let rs = new ReplaySubject<Talkgroup>();
@ -62,10 +92,10 @@ export class TalkgroupService {
}
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
.put<Talkgroup>(`/api/talkgroup/${tu.system_id}/${tu.tgid}`, tu)
.put<Talkgroup>(`/api/talkgroup/${tu.systemId}/${tu.tgid}`, tu)
.pipe(
switchMap((tg) => {
let tObs = this._getTalkgroup.get(tgid);
@ -100,7 +130,7 @@ export class TalkgroupService {
this.subscriptions.add(
this.tgs$.subscribe((tgs) => {
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);
if (rs) {
(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",
"secure": false
}

View file

@ -1,9 +1,11 @@
package main
import (
"bufio"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"net/http"
"net/http/cookiejar"
@ -11,10 +13,12 @@ import (
"os"
"os/signal"
"strings"
"syscall"
"time"
"dynatron.me/x/stillbox/internal/version"
"dynatron.me/x/stillbox/pkg/pb"
"golang.org/x/term"
"github.com/gorilla/websocket"
"google.golang.org/protobuf/proto"
@ -37,6 +41,31 @@ func userAgent(h http.Header) {
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() {
flag.Parse()
log.SetFlags(0)
@ -53,6 +82,8 @@ func main() {
signal.Notify(interrupt, os.Interrupt)
play := NewPlayer()
getCreds()
loginForm := url.Values{}
loginForm.Add("username", *username)
loginForm.Add("password", *password)

10
go.mod
View file

@ -5,6 +5,7 @@ go 1.23.2
require (
dynatron.me/x/go-minimp3 v0.0.0-20240805171536-7ea857e216d6
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-chi/chi/v5 v5.1.0
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/file v1.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/rs/zerolog v1.33.0
github.com/stretchr/testify v1.10.0
@ -28,7 +31,7 @@ require (
github.com/urfave/cli/v2 v2.27.5
golang.org/x/crypto v0.29.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
gopkg.in/yaml.v3 v3.0.1
)
@ -39,7 +42,6 @@ require (
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/davecgh/go-spew v1.1.1 // 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/go-audio/audio 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/httprc v1.0.6 // 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/matoous/go-nanoid v1.5.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/image v0.22.0 // 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
)

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/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/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/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/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/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/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
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/go.mod h1:q6uFgbgZfEmQrfJfrCo90QcQOcXFMfbI/fO0NqRtvZo=
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.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.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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
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.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
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/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
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-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/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/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-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.6.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.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU=
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
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.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
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/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
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
Go(context.Context)
HUP(*config.Config)
stats
}
@ -101,9 +102,7 @@ func New(cfg config.Alerting, tgCache tgstore.Store, opts ...AlertOption) Alerte
tgCache: tgCache,
}
if cfg.Renotify != nil {
as.renotify = cfg.Renotify.Duration()
}
as.reload()
for _, opt := range opts {
opt(as)
@ -122,6 +121,21 @@ func New(cfg config.Alerting, tgCache tgstore.Store, opts ...AlertOption) Alerte
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.
func (as *alerter) Go(ctx context.Context) {
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 {
origScore := s.Score
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
}
@ -376,3 +395,4 @@ func (*noopAlerter) SinkType() string { return "noopA
func (*noopAlerter) Call(_ context.Context, _ *calls.Call) error { return nil }
func (*noopAlerter) Go(_ context.Context) {}
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.
VerifyMiddleware() func(http.Handler) http.Handler
// InstallAuthMiddleware installs the JWT authenticator middleware to the provided chi Router.
AuthMiddleware() func(http.Handler) http.Handler
// SubjectMiddleware sets the request context subject from JWT or public.
SubjectMiddleware(requireAuth bool) func(http.Handler) http.Handler
// PublicRoutes installs the auth route to the provided chi Router.
PublicRoutes(chi.Router)
@ -84,22 +84,39 @@ func TokenFromCookie(r *http.Request) string {
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 {
hfn := func(w http.ResponseWriter, r *http.Request) {
token, _, err := jwtauth.FromContext(r.Context())
if err != nil {
if err != nil && requireToken {
http.Error(w, err.Error(), http.StatusUnauthorized)
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()
sub, err := users.FromCtx(ctx).GetUser(ctx, username)
if err != nil {
log.Error().Str("username", username).Err(err).Msg("subject middleware get subject")
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
@ -111,8 +128,9 @@ func (a *Auth) AuthMiddleware() func(http.Handler) http.Handler {
return
}
// Token is authenticated, pass it through
next.ServeHTTP(w, r)
// Public subject
ctx = entities.CtxWithSubject(ctx, entities.NewPublicSubject(r))
next.ServeHTTP(w, r.WithContext(ctx))
}
return http.HandlerFunc(hfn)
}
@ -211,7 +229,7 @@ func (a *Auth) routeRefresh(w http.ResponseWriter, r *http.Request) {
}
if cookie.Secure {
cookie.Domain = r.Host
cookie.Domain = strings.Split(r.Host, ":")[0]
}
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
}
cookie.Domain = r.Host
cookie.Domain = strings.Split(r.Host, ":")[0]
if a.allowInsecureCookie(r) {
a.setInsecureCookie(cookie)
}
@ -297,7 +315,7 @@ func (a *Auth) routeLogout(w http.ResponseWriter, r *http.Request) {
MaxAge: -1,
}
cookie.Domain = r.Host
cookie.Domain = strings.Split(r.Host, ":")[0]
if a.allowInsecureCookie(r) {
cookie.Secure = true
cookie.SameSite = http.SameSiteNoneMode

View file

@ -3,9 +3,11 @@ package calls
import (
"encoding/json"
"fmt"
"net/url"
"time"
"dynatron.me/x/stillbox/internal/audio"
"dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/pb"
"dynatron.me/x/stillbox/pkg/rbac/entities"
@ -22,6 +24,13 @@ func (d CallDuration) Duration() time.Duration {
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 {
if time.Duration(d) == 0 {
return nil
@ -59,18 +68,19 @@ type Call struct {
AudioType string `json:"audioType,omitempty" relayOut:"audioType,omitempty"`
AudioURL *string `json:"audioURL,omitempty" relayOut:"audioURL,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"`
Frequency int `json:"frequency,omitempty" relayOut:"frequency,omitempty"`
Patches []int `json:"patches,omitempty" relayOut:"patches,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"`
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"`
TalkgroupGroup *string `json:"talkgroupGroup,omitempty" relayOut:"talkgroupGroup,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:"-"`
}
@ -87,6 +97,15 @@ func (c *Call) ShouldStore() bool {
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) {
err := call.computeLength()
if err != nil {

View file

@ -74,6 +74,7 @@ func toAddCallParams(call *calls.Call) database.AddCallParams {
AudioName: common.NilIfZero(call.AudioName),
AudioBlob: call.Audio,
AudioType: common.NilIfZero(call.AudioType),
AudioUrl: call.AudioURL,
Duration: call.Duration.MsInt32Ptr(),
Frequency: call.Frequency,
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) {
_, 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 {
return nil, err
}
@ -178,6 +179,7 @@ func (s *store) Call(ctx context.Context, id uuid.UUID) (*calls.Call, error) {
TalkgroupLabel: c.TGLabel,
TalkgroupGroup: c.TGGroup,
TGAlphaTag: c.TGAlphaTag,
Transcript: c.Transcript,
}, nil
}

View file

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

View file

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

View file

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

View file

@ -1924,6 +1924,122 @@ func (_c *Store_GetShare_Call) RunAndReturn(run func(context.Context, string) (d
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
func (_m *Store) GetSystemName(ctx context.Context, systemID int) (string, error) {
ret := _m.Called(ctx, systemID)

View file

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

View file

@ -42,6 +42,8 @@ type Querier interface {
GetIncidentOwner(ctx context.Context, id uuid.UUID) (int, error)
GetIncidentTalkgroups(ctx context.Context, incidentID uuid.UUID) ([]GetIncidentTalkgroupsRow, 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)
GetTalkgroup(ctx context.Context, systemID int32, tGID int32) (GetTalkgroupRow, 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 {
ID string `json:"id"`
EntityType string `json:"entity_type"`
EntityID uuid.UUID `json:"entity_id"`
EntityDate pgtype.Timestamptz `json:"entity_date"`
EntityType string `json:"entityType"`
EntityID uuid.UUID `json:"entityId"`
EntityDate pgtype.Timestamptz `json:"entityDate"`
Owner int `json:"owner"`
Expiration pgtype.Timestamptz `json:"expiration"`
}
@ -55,14 +55,14 @@ func (q *Queries) DeleteShare(ctx context.Context, id string) error {
const getShare = `-- name: GetShare :one
SELECT
id,
entity_type,
entity_id,
entity_date,
owner,
expiration
FROM shares
WHERE id = $1
s.id,
s.entity_type,
s.entity_id,
s.entity_date,
s.owner,
s.expiration
FROM shares s
WHERE s.id = $1
`
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
}
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
DELETE FROM shares WHERE expiration < NOW()
`

View file

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

View file

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

View file

@ -2,7 +2,11 @@ package incidents
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/rbac/entities"
@ -14,19 +18,35 @@ type Incident struct {
ID uuid.UUID `json:"id"`
Owner users.UserID `json:"owner"`
Name string `json:"name"`
Description *string `json:"description"`
StartTime *jsontypes.Time `json:"startTime"`
EndTime *jsontypes.Time `json:"endTime"`
Location jsontypes.Location `json:"location"`
Metadata jsontypes.Metadata `json:"metadata"`
Description *string `json:"description,omitempty"`
CreatedAt jsontypes.Time `json:"createdAt"`
StartTime *jsontypes.Time `json:"startTime,omitempty"`
EndTime *jsontypes.Time `json:"endTime,omitempty"`
Location jsontypes.Location `json:"location,omitempty"`
Metadata jsontypes.Metadata `json:"metadata,omitempty"`
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 {
return entities.ResourceIncident
}
func (inc *Incident) PlaylistFilename() string {
rep := strings.NewReplacer(" ", "_", "/", "_", ":", "_")
return rep.Replace(strings.ToLower(inc.Name))
}
type IncidentCall struct {
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(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)
}
@ -231,6 +231,7 @@ func fromDBIncident(id uuid.UUID, d database.Incident) incidents.Incident {
Owner: users.UserID(d.Owner),
Name: d.Name,
Description: d.Description,
CreatedAt: jsontypes.Time(d.CreatedAt.Time),
StartTime: jsontypes.TimePtrFromTSTZ(d.StartTime),
EndTime: jsontypes.TimePtrFromTSTZ(d.EndTime),
Metadata: d.Metadata,
@ -250,6 +251,7 @@ func fromDBListInPRow(id uuid.UUID, d database.ListIncidentsPRow) Incident {
Owner: users.UserID(d.Owner),
Name: d.Name,
Description: d.Description,
CreatedAt: jsontypes.Time(d.CreatedAt.Time),
StartTime: jsontypes.TimePtrFromTSTZ(d.StartTime),
EndTime: jsontypes.TimePtrFromTSTZ(d.EndTime),
Metadata: d.Metadata,
@ -268,6 +270,7 @@ func fromDBCalls(d []database.GetIncidentCallsRow) []incidents.IncidentCall {
ID: v.CallID,
AudioName: common.ZeroIfNil(v.AudioName),
AudioType: common.ZeroIfNil(v.AudioType),
AudioURL: v.AudioUrl,
Duration: dur,
DateTime: v.CallDate.Time,
Frequencies: v.Frequencies,

View file

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

View file

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

View file

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

View file

@ -8,17 +8,23 @@ import (
"github.com/el-mike/restrict/v2"
"github.com/el-mike/restrict/v2/adapters"
"github.com/rs/zerolog/log"
)
var (
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 {
return accessErr
}
if err == ErrAccessDenied {
return err
}
return nil
}
@ -115,5 +121,19 @@ func (r *rbac) Check(ctx context.Context, res restrict.Resource, opts ...CheckOp
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),
users: new(usersAPI),
}
s.shares = newShareAPI(&baseURL,
ShareHandlers{
ShareRequestCall: s.calls.shareCallRoute,
ShareRequestCallDL: s.calls.shareCallDLRoute,
ShareRequestIncident: s.incidents.getIncident,
ShareRequestIncidentM3U: s.incidents.getCallsM3U,
ShareRequestTalkgroups: s.tgs.getTGsShareRoute,
},
)
s.shares = newShareAPI(&baseURL, s.shareHandlers())
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)
}

View file

@ -1,6 +1,7 @@
package rest
import (
"context"
"errors"
"fmt"
"mime"
@ -11,7 +12,6 @@ import (
"dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/pkg/calls/callstore"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/shares"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
@ -102,7 +102,12 @@ func (ca *callsAPI) getAudio(p getAudioParams, w http.ResponseWriter, r *http.Re
_, _ = 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{
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)
}
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{
CallID: common.PtrTo(id.(uuid.UUID)),
Download: common.PtrTo("download"),

View file

@ -2,6 +2,7 @@ package rest
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
@ -12,7 +13,6 @@ import (
"dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/incidents"
"dynatron.me/x/stillbox/pkg/incidents/incstore"
"dynatron.me/x/stillbox/pkg/shares"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"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) {
id, err := idOnlyParam(w, r)
if err != nil {
wErr(w, r, autoError(err))
return
}
ia.getIncident(id, nil, w, r)
}
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))
e, err := ia.getIncident(r.Context(), id)
if err != nil {
wErr(w, r, autoError(err))
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) {
@ -195,10 +195,10 @@ func (ia *incidentsAPI) getCallsM3URoute(w http.ResponseWriter, r *http.Request)
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()
incs := incstore.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)
urlRoot := "/api/call"
filename := inc.PlaylistFilename()
share := ShareFrom(ctx)
if share != nil {
urlRoot = fmt.Sprintf("/share/%s/call/", share.ID)
filename += "_" + share.ID
}
filename += ".m3u"
b.WriteString("#EXTM3U\n\n")
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()
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(),
tg.StringTag(true),
from,
c.DateTime.Format("15:04 01/02"),
c.Duration.ColonFormat(),
c.DateTime.Format("15:04:05 01/02"),
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,
// let's hope this is good enough
w.Header().Set("Content-Type", "audio/x-mpegurl")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
w.WriteHeader(http.StatusOK)
_, _ = b.WriteTo(w)
}

View file

@ -1,6 +1,7 @@
package rest
import (
"context"
"errors"
"net/http"
"net/url"
@ -23,25 +24,29 @@ type ShareRequestType string
const (
ShareRequestCall ShareRequestType = "call"
ShareRequestCallInfo ShareRequestType = "callinfo"
ShareRequestCallDL ShareRequestType = "callDL"
ShareRequestIncident ShareRequestType = "incident"
ShareRequestIncidentM3U ShareRequestType = "m3u"
ShareRequestTalkgroups ShareRequestType = "talkgroups"
)
func (rt ShareRequestType) IsValid() bool {
switch rt {
case ShareRequestCall, ShareRequestCallDL, ShareRequestIncident,
ShareRequestIncidentM3U, ShareRequestTalkgroups:
return true
// shareHandlers returns a ShareHandlers map from the api.
func (s *api) shareHandlers() ShareHandlers {
return ShareHandlers{
ShareRequestCall: s.calls.shareCallRoute,
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 {
case ShareRequestCall, ShareRequestCallDL, ShareRequestTalkgroups:
case ShareRequestCall, ShareRequestCallInfo, ShareRequestCallDL, ShareRequestIncident,
ShareRequestIncidentM3U, ShareRequestTalkgroups:
return true
}
@ -51,13 +56,59 @@ func (rt ShareRequestType) IsValidSubtype() bool {
type ID interface {
}
type HandlerFunc func(id ID, share *shares.Share, w http.ResponseWriter, r *http.Request)
type ShareHandlers map[ShareRequestType]HandlerFunc
type ShareHandlerFunc func(id ID, w http.ResponseWriter, r *http.Request)
type ShareHandlers map[ShareRequestType]ShareHandlerFunc
type shareAPI struct {
baseURL *url.URL
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 {
return &shareAPI{
baseURL: baseURL,
@ -70,6 +121,7 @@ func (sa *shareAPI) Subrouter() http.Handler {
r.Post(`/create`, sa.createShare)
r.Delete(`/{id:[A-Za-z0-9_-]{20,}}`, sa.deleteShare)
r.Post(`/`, sa.listShares)
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,}}/{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)
return r
}
@ -104,6 +157,34 @@ func (sa *shareAPI) createShare(w http.ResponseWriter, r *http.Request) {
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) {
ctx := r.Context()
shs := shares.FromCtx(ctx)
@ -134,12 +215,11 @@ func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) {
} else {
switch sh.Type {
case shares.EntityCall:
rType = ShareRequestCall
rType = ShareRequestCallInfo
params.SubID = common.PtrTo(sh.EntityID.String())
case shares.EntityIncident:
rType = ShareRequestIncident
}
w.Header().Set("X-Share-Type", string(rType))
}
if !rType.IsValid() {
@ -157,23 +237,44 @@ func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) {
switch rType {
case ShareRequestTalkgroups:
sa.shnd[rType](nil, sh, w, r)
case ShareRequestCall, ShareRequestCallDL:
if params.SubID == nil {
wErr(w, r, autoError(ErrBadShare))
return
sa.shnd[rType](nil, w, r)
case ShareRequestCall, ShareRequestCallInfo, ShareRequestCallDL:
var subIDU uuid.UUID
if params.SubID != nil {
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)
if err != nil {
wErr(w, r, badRequest(err))
return
}
sa.shnd[rType](subIDU, sh, w, r)
sa.shnd[rType](subIDU, w, r)
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) {
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"
"fmt"
"net/http"
"slices"
"dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/incidents/incstore"
"dynatron.me/x/stillbox/pkg/shares"
"dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"dynatron.me/x/stillbox/pkg/talkgroups/xport"
@ -161,10 +161,16 @@ func (tga *talkgroupAPI) postPaginated(w http.ResponseWriter, r *http.Request) {
respond(w, r, res)
}
func (tga *talkgroupAPI) getTGsShareRoute(_ ID, share *shares.Share, w http.ResponseWriter, r *http.Request) {
func (tga *talkgroupAPI) getTGsShareRoute(_ ID, w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
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)
if err != nil {
wErr(w, r, autoError(err))
@ -176,6 +182,14 @@ func (tga *talkgroupAPI) getTGsShareRoute(_ ID, share *shares.Share, w http.Resp
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)
if err != nil {
wErr(w, r, autoError(err))

View file

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

View file

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

View file

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

View file

@ -3,7 +3,9 @@ package shares
import (
"context"
"errors"
"fmt"
"dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/rbac"
@ -12,13 +14,21 @@ import (
"github.com/jackc/pgx/v5"
)
type SharesParams struct {
common.Pagination
Direction *common.SortDirection `json:"dir"`
}
type Shares interface {
// NewShare creates a new share.
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)
// 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(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) {
_, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceShare), rbac.WithActions(entities.ActionRead))
if err != nil {
return nil, err
}
db := database.FromCtx(ctx)
rec, err := db.GetShare(ctx, id)
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 {
_, 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 {
return err
}
@ -88,6 +108,54 @@ func (s *postgresStore) Delete(ctx context.Context, id string) error {
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 {
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)
if err != nil {
if rbac.ErrAccessDenied(err) != nil {
if rbac.IsErrAccessDenied(err) != nil {
log.Error().Err(err).Msg("ingest failed")
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 {
// talkgroups don't have owners, so we can use a generic Resource
_, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionDelete))
if err != nil {
return err

File diff suppressed because one or more lines are too long

View file

@ -2,11 +2,16 @@ package users
import (
"context"
"errors"
"dynatron.me/x/stillbox/internal/cache"
"dynatron.me/x/stillbox/pkg/database"
)
var (
ErrNoSuchUser = errors.New("no such user")
)
type Store interface {
// GetUser gets a user by UID.
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)
if err != nil {
if database.IsNoRows(err) {
return nil, ErrNoSuchUser
}
return nil, err
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,13 +1,13 @@
-- name: GetShare :one
SELECT
id,
entity_type,
entity_id,
entity_date,
owner,
expiration
FROM shares
WHERE id = @id;
s.id,
s.entity_type,
s.entity_id,
s.entity_date,
s.owner,
s.expiration
FROM shares s
WHERE s.id = @id;
-- name: CreateShare :exec
INSERT INTO shares (
@ -24,3 +24,27 @@ DELETE FROM shares WHERE id = @id;
-- name: PruneShares :exec
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
emit_json_tags: true
emit_interface: true
json_tags_case_style: camel
initialisms:
- id
- tgid