Merge pull request 'Shares' (#109) from shareUI into trunk
Reviewed-on: #109
This commit is contained in:
commit
83241684e0
82 changed files with 1875 additions and 510 deletions
2
LICENSE
2
LICENSE
|
@ -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:
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -26,5 +26,16 @@
|
|||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"navigationUrls": [
|
||||
"/**",
|
||||
"!/**/*.*",
|
||||
"!/**/****",
|
||||
"!/**/****/**",
|
||||
"!/tgstats",
|
||||
"!/tgstats/**",
|
||||
"!/api/**",
|
||||
"!/share/**",
|
||||
"!/testnotify"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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()}`,
|
||||
|
|
|
@ -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' },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
<mat-card class="callInfo" appearance="outlined">
|
||||
<div class="cardHdr">
|
||||
<h1>
|
||||
{{ call | talkgroup: "alpha" | async }} @
|
||||
{{ 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>
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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">
|
||||
|
|
|
@ -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);
|
||||
}),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}),
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -101,6 +101,11 @@ export class NavigationComponent {
|
|||
url: '/alerts',
|
||||
icon: 'notifications',
|
||||
},
|
||||
{
|
||||
name: 'Shares',
|
||||
url: '/shares',
|
||||
icon: 'share',
|
||||
},
|
||||
];
|
||||
|
||||
toggleFilterPanel() {
|
||||
|
|
17
client/stillbox/src/app/share/share.component.html
Normal file
17
client/stillbox/src/app/share/share.component.html
Normal 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>
|
||||
}
|
||||
}
|
0
client/stillbox/src/app/share/share.component.scss
Normal file
0
client/stillbox/src/app/share/share.component.scss
Normal file
22
client/stillbox/src/app/share/share.component.spec.ts
Normal file
22
client/stillbox/src/app/share/share.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
41
client/stillbox/src/app/share/share.component.ts
Normal file
41
client/stillbox/src/app/share/share.component.ts
Normal 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);
|
||||
}
|
||||
}
|
16
client/stillbox/src/app/share/share.service.spec.ts
Normal file
16
client/stillbox/src/app/share/share.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
81
client/stillbox/src/app/share/share.service.ts
Normal file
81
client/stillbox/src/app/share/share.service.ts
Normal 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!);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
8
client/stillbox/src/app/shares.ts
Normal file
8
client/stillbox/src/app/shares.ts
Normal 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;
|
||||
}
|
82
client/stillbox/src/app/shares/shares.component.html
Normal file
82
client/stillbox/src/app/shares/shares.component.html
Normal 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>
|
0
client/stillbox/src/app/shares/shares.component.scss
Normal file
0
client/stillbox/src/app/shares/shares.component.scss
Normal file
22
client/stillbox/src/app/shares/shares.component.spec.ts
Normal file
22
client/stillbox/src/app/shares/shares.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
192
client/stillbox/src/app/shares/shares.component.ts
Normal file
192
client/stillbox/src/app/shares/shares.component.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
{
|
||||
"/api": {
|
||||
"/api/": {
|
||||
"target": "http://xenon:3050",
|
||||
"secure": false
|
||||
},
|
||||
"/share/": {
|
||||
"target": "http://xenon:3050",
|
||||
"secure": false
|
||||
}
|
||||
|
|
|
@ -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
10
go.mod
|
@ -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
23
go.sum
|
@ -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=
|
||||
|
|
|
@ -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) {}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
`
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
}
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -18,6 +18,7 @@ func (s *Server) huppers() []hupper {
|
|||
s.logger,
|
||||
s.auth,
|
||||
s.tgs,
|
||||
s.alerter,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -180,6 +180,7 @@ SELECT
|
|||
tg_label,
|
||||
tg_alpha_tag,
|
||||
tg_group,
|
||||
source
|
||||
source,
|
||||
transcript
|
||||
FROM calls
|
||||
WHERE id = @id;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
;
|
||||
|
|
|
@ -11,6 +11,7 @@ sql:
|
|||
query_parameter_limit: 3
|
||||
emit_json_tags: true
|
||||
emit_interface: true
|
||||
json_tags_case_style: camel
|
||||
initialisms:
|
||||
- id
|
||||
- tgid
|
||||
|
|
Loading…
Add table
Reference in a new issue