Compare commits

...

14 commits

Author SHA1 Message Date
dd186b9e30 Admin import 2024-11-19 12:04:03 -05:00
4f63b4b26c depupdate 2024-11-19 10:26:56 -05:00
f779242995 Merge pull request 'callsnormalize' (#40) from callsnormalize into trunk
Reviewed-on: #40
2024-11-19 10:18:12 -05:00
f7fdaf9867 don't alert ignored 2024-11-19 10:18:12 -05:00
711c8fbad0 tests 2024-11-19 10:18:12 -05:00
f6cb8b4d17 fixes 2024-11-19 10:18:12 -05:00
78f6df5920 Flatten migrations 2024-11-19 10:18:12 -05:00
ef69d95583 Initial calls normalization
fix

wip
2024-11-19 10:18:12 -05:00
584ad46679 comment test 2024-11-18 18:50:34 -05:00
f6595d7463 Merge pull request 'Add relayer' (#37) from relay into trunk
Reviewed-on: #37
2024-11-18 18:40:17 -05:00
af81ae32ef Relay 2024-11-18 18:31:17 -05:00
ec33e568d5 Add relay 2024-11-18 14:27:12 -05:00
a318242de2 add marshal 2024-11-18 14:26:39 -05:00
4eddd7d1df Learned has uuids 2024-11-15 23:25:57 -05:00
111 changed files with 19476 additions and 2277 deletions

3
.gitignore vendored
View file

@ -1,5 +1,6 @@
config.yaml
config.test.yaml
config.*.yaml
!config.sample.yaml
/*.sql
client/calls/
!client/calls/.gitkeep

View file

@ -7,4 +7,4 @@ packages:
dynatron.me/x/stillbox/pkg/database:
config:
interfaces:
DB:
Store:

View file

@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

42
client/admin/.gitignore vendored Normal file
View file

@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

View file

@ -0,0 +1,3 @@
# Ignore artifacts:
build
coverage

1
client/admin/.prettierrc Normal file
View file

@ -0,0 +1 @@
{}

4
client/admin/.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
client/admin/.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

42
client/admin/.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}

27
client/admin/README.md Normal file
View file

@ -0,0 +1,27 @@
# Admin
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.2.10.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

90
client/admin/angular.json Normal file
View file

@ -0,0 +1,90 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"admin": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/admin",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": ["src/styles.css"],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kB",
"maximumError": "4kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "admin:build:production"
},
"development": {
"buildTarget": "admin:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": ["zone.js", "zone.js/testing"],
"tsConfig": "tsconfig.spec.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": ["src/styles.css"],
"scripts": []
}
}
}
}
},
"cli": {
"analytics": false
}
}

14635
client/admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

43
client/admin/package.json Normal file
View file

@ -0,0 +1,43 @@
{
"name": "admin",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "^18.2.0",
"@angular/common": "^18.2.0",
"@angular/compiler": "^18.2.0",
"@angular/core": "^18.2.0",
"@angular/forms": "^18.2.0",
"@angular/platform-browser": "^18.2.0",
"@angular/platform-browser-dynamic": "^18.2.0",
"@angular/router": "^18.2.0",
"@ng-icons/core": "^29.6.1",
"@ng-icons/ionicons": "^29.6.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.10"
},
"devDependencies": {
"@angular-devkit/build-angular": "^18.2.10",
"@angular/cli": "^18.2.10",
"@angular/compiler-cli": "^18.2.0",
"@types/jasmine": "~5.1.0",
"daisyui": "^4.12.13",
"jasmine-core": "~5.2.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"prettier": "3.3.3",
"tailwindcss": "^3.4.14",
"typescript": "~5.5.2"
}
}

View file

@ -0,0 +1 @@
<p>alerts works!</p>

View file

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

View file

@ -0,0 +1,10 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-alerts',
standalone: true,
imports: [],
templateUrl: './alerts.component.html',
styleUrl: './alerts.component.css',
})
export class AlertsComponent {}

View file

@ -0,0 +1,3 @@
.stillboxLogo {
font-family: "Warnes";
}

View file

@ -0,0 +1,302 @@
<!-- ========== HEADER ========== -->
@if (auth.loggedIn) {
<header
class="sticky top-0 inset-x-0 flex flex-wrap md:justify-start md:flex-nowrap z-[48] w-full bg-white border-b text-sm py-2.5 lg:ps-[260px] dark:bg-neutral-800 dark:border-neutral-700"
>
<nav class="px-4 sm:px-6 flex basis-full items-center w-full mx-auto">
<div class="me-5 lg:me-0 lg:hidden">
<!-- Logo -->
<a
class="flex-none rounded-md text-xl inline-block stillboxLogo focus:outline-none focus:opacity-80"
href="#"
aria-label="Stillbox"
>
Stillbox
</a>
<!-- End Logo -->
</div>
<div
class="w-full flex items-center justify-end ms-auto md:justify-between gap-x-1 md:gap-x-3"
>
<div class="hidden md:block">
<!-- Search Input -->
<div class="relative">
<div
class="absolute inset-y-0 start-0 flex items-center pointer-events-none z-20 ps-3.5"
>
<svg
class="shrink-0 size-4 text-gray-400 dark:text-white/60"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
</div>
<input
type="text"
class="input py-2 ps-10 pe-16 block w-full bg-white border-gray-200 rounded-lg text-sm focus:outline-none focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder:text-neutral-400 dark:focus:ring-neutral-600 input-bordered"
placeholder="Search"
/>
<div
class="hidden absolute inset-y-0 end-0 flex items-center pointer-events-none z-20 pe-1"
>
<button
type="button"
class="inline-flex shrink-0 justify-center items-center size-6 rounded-full text-gray-500 hover:text-blue-600 focus:outline-none focus:text-blue-600 dark:text-neutral-500 dark:hover:text-blue-500 dark:focus:text-blue-500"
aria-label="Close"
>
<span class="sr-only">Close</span>
<svg
class="shrink-0 size-4"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="m15 9-6 6" />
<path d="m9 9 6 6" />
</svg>
</button>
</div>
<div
class="absolute inset-y-0 end-0 flex items-center pointer-events-none z-20 pe-3 text-gray-400"
>
<span class="mx-1"> </span>
<span class="text-xs">/</span>
</div>
</div>
<!-- End Search Input -->
</div>
<div class="flex flex-row items-center justify-end gap-1">
<button
type="button"
class="md:hidden size-[38px] relative inline-flex justify-center items-center gap-x-2 text-sm font-semibold rounded-full border border-transparent text-gray-800 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700"
>
<svg
class="shrink-0 size-4"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<span class="sr-only">Search</span>
</button>
<a (click)="logout()" class="btn btn-sm btn-primary">Logout</a>
</div>
</div>
</nav>
</header>
} @else {
<div class="relative flex flex-col h-full max-h-full">
<div class="px-6 pt-4">
<!-- Logo -->
<a
class="stillboxLogo flex-none rounded-xl text-xl inline-block font-semibold focus:outline-none focus:opacity-80"
href="#"
aria-label="Stillbox"
>
Stillbox
</a>
<!-- End Logo -->
</div>
</div>
}
<!-- ========== END HEADER ========== -->
@if (auth.loggedIn) {
<div class="-mt-px">
<!-- Breadcrumb -->
<div
class="sticky top-0 inset-x-0 z-20 bg-white border-y px-4 sm:px-6 lg:px-8 lg:hidden dark:bg-neutral-800 dark:border-neutral-700"
>
<div class="flex items-center py-2">
<!-- Navigation Toggle -->
<button
type="button"
class="size-8 flex justify-center items-center gap-x-2 border border-gray-200 text-gray-800 hover:text-gray-500 rounded-lg focus:outline-none focus:text-gray-500 disabled:opacity-50 disabled:pointer-events-none dark:border-neutral-700 dark:text-neutral-200 dark:hover:text-neutral-500 dark:focus:text-neutral-500"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls="hs-application-sidebar"
aria-label="Toggle navigation"
data-hs-overlay="#hs-application-sidebar"
>
<span class="sr-only">Toggle Navigation</span>
<svg
class="shrink-0 size-4"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M15 3v18" />
<path d="m8 9 3 3-3 3" />
</svg>
</button>
<!-- End Navigation Toggle -->
<!-- Breadcrumb -->
<ol class="ms-3 flex items-center whitespace-nowrap">
<li
class="flex items-center text-sm text-gray-800 dark:text-neutral-400"
>
Application Layout
<svg
class="shrink-0 mx-3 overflow-visible size-2.5 text-gray-400 dark:text-neutral-500"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 1L10.6869 7.16086C10.8637 7.35239 10.8637 7.64761 10.6869 7.83914L5 14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</li>
<li
class="text-sm font-semibold text-gray-800 truncate dark:text-neutral-400"
aria-current="page"
>
Dashboard
</li>
</ol>
<!-- End Breadcrumb -->
</div>
</div>
<!-- End Breadcrumb -->
</div>
<!-- Sidebar -->
<div
id="hs-application-sidebar"
class="hs-overlay [--auto-close:lg] hs-overlay-open:translate-x-0 -translate-x-full transition-all duration-300 transform w-[260px] h-full hidden fixed inset-y-0 start-0 z-[60] bg-white border-e border-gray-200 lg:block lg:translate-x-0 lg:end-auto lg:bottom-0 dark:bg-neutral-800 dark:border-neutral-700"
role="dialog"
tabindex="-1"
aria-label="Sidebar"
>
<div class="relative flex flex-col h-full max-h-full">
<div class="px-6 pt-4">
<!-- Logo -->
<a
class="stillboxLogo flex-none rounded-xl text-xl inline-block font-semibold focus:outline-none focus:opacity-80"
href="#"
aria-label="Stillbox"
>
Stillbox
</a>
<!-- End Logo -->
</div>
<!-- Content -->
<div
class="h-full overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-gray-100 [&::-webkit-scrollbar-thumb]:bg-gray-300 dark:[&::-webkit-scrollbar-track]:bg-neutral-700 dark:[&::-webkit-scrollbar-thumb]:bg-neutral-500"
>
<nav
class="hs-accordion-group p-3 w-full flex flex-col flex-wrap"
data-hs-accordion-always-open
>
<ul class="flex flex-col space-y-1">
<li>
<a
class="w-full flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-700 dark:text-neutral-200 dark:hover:text-neutral-300"
routerLink="/home"
routerLinkActive="btn-active"
>
<ng-icon name="ionHome"></ng-icon>Home
</a>
</li>
<li>
<a
class="w-full flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-700 dark:text-neutral-200 dark:hover:text-neutral-300"
routerLink="/talkgroups"
routerLinkActive="btn-active"
href="#"
>
<ng-icon name="ionChatbubbles"></ng-icon>Talkgroups
</a>
</li>
<li>
<a
class="w-full flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-700 dark:text-neutral-200 dark:hover:text-neutral-300"
routerLink="/calls"
routerLinkActive="btn-active"
href="#"
>
<ng-icon name="ionMegaphoneOutline"></ng-icon>Calls
</a>
</li>
<li>
<a
class="w-full flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-900 dark:text-neutral-200 dark:hover:text-neutral-300"
routerLink="/incidents"
routerLinkActive="btn-active"
href="#"
>
<ng-icon name="ionNewspaperOutline"></ng-icon>Incidents
</a>
</li>
<li>
<a
class="w-full flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-900 dark:text-neutral-200 dark:hover:text-neutral-300"
routerLink="/alerts"
routerLinkActive="btn-active"
>
<ng-icon name="ionAlertCircleOutline"></ng-icon>Alerts
</a>
</li>
</ul>
</nav>
</div>
<!-- End Content -->
</div>
</div>
}
<!-- End Sidebar -->
<!-- Content -->
@if (auth.loggedIn) {
<div class="w-full lg:ps-64">
<div class="p-4 sm:p-6 space-y-4 sm:space-y-6">
<router-outlet />
</div>
</div>
} @else {
<div class="w-full">
<div class="p-4 sm:p-6 space-y-4 sm:space-y-6">
<router-outlet />
</div>
</div>
}

View file

@ -0,0 +1,29 @@
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have the 'admin' title`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('admin');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, admin');
});
});

View file

@ -0,0 +1,49 @@
import { Component, inject } from '@angular/core';
import { RouterModule, RouterOutlet, RouterLink } from '@angular/router';
import { CommonModule } from '@angular/common';
import { AuthService } from './login/auth.service';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import {
ionMenuOutline,
ionChatbubbles,
ionNewspaperOutline,
ionAlertCircleOutline,
ionRadioOutline,
ionHome,
ionMegaphoneOutline,
ionCreateOutline,
} from '@ng-icons/ionicons';
@Component({
selector: 'app-root',
standalone: true,
imports: [
CommonModule,
RouterOutlet,
RouterModule,
RouterLink,
NgIconComponent,
],
templateUrl: './app.component.html',
styleUrl: './app.component.css',
providers: [
provideIcons({
ionMenuOutline,
ionChatbubbles,
ionNewspaperOutline,
ionAlertCircleOutline,
ionRadioOutline,
ionHome,
ionMegaphoneOutline,
ionCreateOutline,
}),
],
})
export class AppComponent {
auth: AuthService = inject(AuthService);
title = 'admin';
logout() {
this.auth.logout();
}
}

View file

@ -0,0 +1,54 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import {
HttpRequest,
HttpHandlerFn,
HttpEvent,
withInterceptors,
} from '@angular/common/http';
import { Observable } from 'rxjs';
import { isDevMode, inject } from '@angular/core';
import { AuthService } from './login/auth.service';
import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
export function apiBaseInterceptor(
req: HttpRequest<unknown>,
next: HttpHandlerFn,
): Observable<HttpEvent<unknown>> {
let baseUrl: string;
if (isDevMode()) {
baseUrl = 'http://xenon:3050';
} else {
baseUrl = '';
}
const apiReq = req.clone({ url: `${baseUrl}${req.url}` });
return next(apiReq);
}
export function authIntercept(
req: HttpRequest<unknown>,
next: HttpHandlerFn,
): Observable<HttpEvent<unknown>> {
let authSvc: AuthService = inject(AuthService);
if (authSvc.loggedIn) {
req = req.clone({
setHeaders: {
'Content-Type': 'application/json; charset=utf-8',
Accept: 'application/json',
Authorization: `Bearer ${authSvc.getToken()}`,
},
});
}
return next(req);
}
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(withInterceptors([apiBaseInterceptor, authIntercept])),
],
};

View file

@ -0,0 +1,23 @@
import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { LoginComponent } from './login/login.component';
import { TalkgroupsComponent } from './talkgroups/talkgroups.component';
import { TalkgroupRecordComponent } from './talkgroups/talkgroup-record/talkgroup-record.component';
import { CallsComponent } from './calls/calls.component';
import { IncidentsComponent } from './incidents/incidents.component';
import { AlertsComponent } from './alerts/alerts.component';
import { ImportComponent } from './talkgroups/import/import.component';
import { AuthGuard } from './auth.guard';
export const routes: Routes = [
{ path: '', redirectTo: 'login', pathMatch: 'full' },
{ path: 'home', component: HomeComponent, canActivate: [AuthGuard] },
{ path: 'login', component: LoginComponent },
{ path: 'talkgroups', component: TalkgroupsComponent },
{ path: 'talkgroups/import', component: ImportComponent },
{ path: 'talkgroups/:sys/:tg', component: TalkgroupRecordComponent },
{ path: 'calls', component: CallsComponent },
{ path: 'incidents', component: IncidentsComponent },
{ path: 'alerts', component: AlertsComponent },
];

View file

@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { CanActivateFn } from '@angular/router';
import { authGuard } from './auth.guard';
describe('authGuard', () => {
const executeGuard: CanActivateFn = (...guardParameters) =>
TestBed.runInInjectionContext(() => authGuard(...guardParameters));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(executeGuard).toBeTruthy();
});
});

View file

@ -0,0 +1,9 @@
import { CanActivateFn } from '@angular/router';
export const AuthGuard: CanActivateFn = (route, state) => {
if (sessionStorage.getItem('jwt') == null) {
return false;
} else {
return true;
}
};

View file

@ -0,0 +1 @@
<p>calls works!</p>

View file

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

View file

@ -0,0 +1,10 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-calls',
standalone: true,
imports: [],
templateUrl: './calls.component.html',
styleUrl: './calls.component.css',
})
export class CallsComponent {}

View file

@ -0,0 +1 @@
<p>This will be a dashboard someday.</p>

View file

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

View file

@ -0,0 +1,10 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-home',
standalone: true,
imports: [],
templateUrl: './home.component.html',
styleUrl: './home.component.css',
})
export class HomeComponent {}

View file

@ -0,0 +1 @@
<p>incidents works!</p>

View file

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

View file

@ -0,0 +1,10 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-incidents',
standalone: true,
imports: [],
templateUrl: './incidents.component.html',
styleUrl: './incidents.component.css',
})
export class IncidentsComponent {}

View file

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

View file

@ -0,0 +1,54 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpResponse } from '@angular/common/http';
import { Router } from '@angular/router';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
export class Jwt {
constructor(public jwt: string) {}
}
@Injectable({
providedIn: 'root',
})
export class AuthService {
loggedIn: boolean = false;
constructor(
private http: HttpClient,
private _router: Router,
) {
let ssJWT = sessionStorage.getItem('jwt');
if (ssJWT) {
this.loggedIn = true;
this._router.navigateByUrl('/home');
}
}
login(username: string, password: string): Observable<HttpResponse<Jwt>> {
return this.http
.post<Jwt>(
'/api/login',
{ username: username, password: password },
{ observe: 'response' },
)
.pipe(
tap((event) => {
if (event.status == 200) {
sessionStorage.setItem('jwt', event.body?.jwt.toString() ?? '');
this.loggedIn = true;
this._router.navigateByUrl('/home');
}
}),
);
}
getToken(): string | null {
return sessionStorage.getItem('jwt');
}
logout() {
sessionStorage.removeItem('jwt');
this.loggedIn = false;
this._router.navigateByUrl('/login');
}
}

View file

@ -0,0 +1,36 @@
<div class="flex justify-center">
<div class="card w-96 bg-base-100 shadow-xl mt-20 mb-20">
<div class="card-body">
<div class="items-center mt-2">
<label class="input input-bordered flex items-center gap-2 mb-2">
<input
type="text"
[(ngModel)]="username"
class="grow"
placeholder="login"
(keyup.enter)="onSubmit()"
/>
</label>
<label class="input input-bordered flex items-center gap-2 mb-2">
<input
type="password"
[(ngModel)]="password"
class="grow"
placeholder="password"
(keyup.enter)="onSubmit()"
/>
</label>
</div>
<div class="card-actions justify-end">
<button (click)="onSubmit()" class="btn btn-primary w-full">
Login
</button>
</div>
</div>
@if (failed) {
<div role="alert" class="alert alert-error">
<span>Login Failed!</span>
</div>
}
</div>
</div>

View file

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

View file

@ -0,0 +1,39 @@
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AuthService } from '../login/auth.service';
import { catchError, of } from 'rxjs';
import { Router } from '@angular/router';
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule],
templateUrl: './login.component.html',
styleUrl: './login.component.css',
})
export class LoginComponent {
apiService: AuthService = inject(AuthService);
router: Router = inject(Router);
username: string = '';
password: string = '';
failed: boolean = false;
onSubmit() {
this.failed = false;
this.apiService
.login(this.username, this.password)
.pipe(
catchError(() => {
this.failed = true;
return of(null);
}),
)
.subscribe((event) => {
if (event?.status == 200) {
this.router.navigateByUrl('/home');
} else {
this.failed = true;
}
});
}
}

View file

@ -0,0 +1,67 @@
export interface TGID {
sys: number;
tg: number;
}
export class AlertTime {
time: string;
duration: string;
constructor(at: string) {
let s = at.split('+');
this.time = s[0];
this.duration = s[1];
}
}
export class AlertRule {
times: AlertTime[];
mult: number;
constructor(times: AlertTime[], mult: number) {
this.times = times;
this.mult = mult;
}
}
export interface System {
id: number;
name: string;
}
export interface Metadata {
encrypted: boolean|null;
}
export interface Talkgroup {
id: number;
system_id: number;
tgid: number;
name: string;
alpha_tag: string;
tg_group: string;
frequency: number;
metadata: Metadata|null;
tags: string[];
alert: boolean;
alert_config: AlertRule[];
system: System;
weight: number;
learned?: boolean;
selected?: boolean;
}
export interface TalkgroupUpdate {
id: number;
system_id: number;
tgid: number;
name: string | null;
alpha_tag: string | null;
tg_group: string | null;
frequency: number | null;
metadata: Object | null;
tags: string[] | null;
alert: boolean | null;
alert_config: AlertRule[] | null;
weight: number | null;
}

View file

@ -0,0 +1,45 @@
<div class="flex">
<form [formGroup]="form" (ngSubmit)="submit()">
<textarea
id="contents"
class="w-full textarea textarea-bordered"
placeholder="Paste RadioReference page here"
formControlName="contents"
cols="40" rows="15"
></textarea>
<input type="number" class="input input-bordered" formControlName="systemID" id="systemID" />
<input type="submit" class="btn btn-primary" value="Preview" />
<button class="btn btn-secondary" (click)="save()">Save</button>
</form>
</div>
<div class="w-100 justify-center overflow-x-auto">
<table class="table">
<thead>
<tr>
<th><input type="checkbox" class="checkbox" [(ngModel)]="selectAll" name="selectAll" /></th>
<th>Sys</th>
<th>Sys ID</th>
<th>Group</th>
<th>Alpha</th>
<th>Name</th>
<th>TG ID</th>
<th>Enc</th>
</tr>
</thead>
<tbody>
@for (tg of tgs; track tg.id) {
<tr>
<td><input type="checkbox" class="checkbox" [(ngModel)]="tg.selected" value="{{tg.name}}" (change)="isAllSelected()">
</td>
<td>{{ tg.system.name }}</td>
<td>{{ tg.system.id }}</td>
<td>{{ tg.tg_group }}</td>
<td>{{ tg.alpha_tag }}</td>
<td>{{ tg.name }}</td>
<td>{{ tg.tgid }}</td>
<td>{{ tg?.metadata?.encrypted ? 'E' : '' }}</td>
</tr>
}
</tbody>
</table>
</div>

View file

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

View file

@ -0,0 +1,53 @@
import { Component, inject } from '@angular/core';
import { TalkgroupService } from '../talkgroups.service';
import { Talkgroup } from '../../talkgroup';
import { FormGroup, FormControl, ReactiveFormsModule, FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { Router, ActivatedRoute } from '@angular/router';
import { catchError, of } from 'rxjs';
@Component({
selector: 'app-import',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, FormsModule ],
templateUrl: './import.component.html',
styleUrl: './import.component.css',
})
export class ImportComponent {
tgService: TalkgroupService = inject(TalkgroupService);
form!: FormGroup;
tgs!: Talkgroup[];
selectAll: boolean = false;
constructor(
private route: ActivatedRoute,
private router: Router,
) {}
ngOnInit() {
this.form = new FormGroup({
contents: new FormControl(''),
systemID: new FormControl(0),
});
}
submit() {
let content = this.form.controls['contents'].value;
let sysID = Number(this.form.controls['systemID'].value);
this.tgService.importRR(sysID, content)
.pipe(
catchError(() => {
return of(null);
}),
)
.subscribe((event) => {
this.tgs = event!;
//this.router.navigate(['/talkgroups/']);
});
}
isAllSelected() {
}
save() {
}
}

View file

@ -0,0 +1,33 @@
<div class="container flex">
@for (rule of rules; track $index) {
<div class="">
<table class="table">
<thead>
<tr>
<td>Start</td>
<td>Duration</td>
<td></td>
</tr>
</thead>
<tbody>
@for (time of rule.times; track $index) {
<tr>
<td>
{{ time.time }}
</td>
<td>
{{ time.duration }}
</td>
</tr>
} @empty {
<tr>
<td><em>No times</em></td>
</tr>
}
</tbody>
</table>
</div>
} @empty {
No rules
}
</div>

View file

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

View file

@ -0,0 +1,20 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { AlertRule } from '../../../talkgroup';
@Component({
selector: 'alert-rule-builder',
standalone: true,
imports: [],
templateUrl: './alert-rule-builder.component.html',
styleUrl: './alert-rule-builder.component.css',
})
export class AlertRuleBuilderComponent {
@Input() rules: AlertRule[] = [];
@Output() rulesChange: EventEmitter<AlertRule[]> = new EventEmitter<
AlertRule[]
>();
emit() {
this.rulesChange.emit(this.rules);
}
}

View file

@ -0,0 +1,68 @@
<div class="flex">
<form [formGroup]="form" (ngSubmit)="submit()">
<div class="p-1 columns-2">
<label class="w-full" for="name">Name: </label
><input
id="name"
type="text"
class="w-full input input-bordered"
formControlName="name"
[(ngModel)]="tg.name"
/>
</div>
<div class="p-1 columns-2">
<label class="w-full" for="alpha_tag">Alpha Tag: </label
><input
id="alpha_tag"
type="text"
class="w-full input input-bordered"
formControlName="alpha_tag"
[(ngModel)]="tg.alpha_tag"
/>
</div>
<div class="p-1 columns-2">
<label class="w-full" for="tg_group">Group: </label
><input
id="tg_group"
type="text"
class="w-full input input-bordered"
formControlName="tg_group"
[(ngModel)]="tg.tg_group"
/>
</div>
<div class="p-1 columns-2">
<label class="w-full" for="frequency">Frequency: </label
><input
id="frequency"
type="text"
class="w-full input input-bordered"
formControlName="frequency"
[(ngModel)]="tg.frequency"
/>
</div>
<div class="p-1 columns-2">
<label class="w-full" for="alert">Alert: </label>
<div class="w-full">
<input
id="alert"
type="checkbox"
formControlName="alert"
[(ngModel)]="tg.alert"
class="checkbox"
/>
</div>
</div>
<div class="p-1 columns-2">
<label class="w-full" for="weight">Weight: </label
><input
id="weight"
type="text"
class="w-full input input-bordered"
formControlName="weight"
[(ngModel)]="tg.weight"
/>
</div>
<alert-rule-builder [rules]="tg.alert_config" />
<input type="submit" class="btn btn-secondary" value="Save" />
</form>
</div>

View file

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

View file

@ -0,0 +1,89 @@
import { Component, inject } from '@angular/core';
import { Talkgroup, TalkgroupUpdate } from '../../talkgroup';
import { TalkgroupService } from '../talkgroups.service';
import { AlertRuleBuilderComponent } from './alert-rule-builder/alert-rule-builder.component';
import { CommonModule } from '@angular/common';
import { catchError, of } from 'rxjs';
import {
ReactiveFormsModule,
FormGroup,
FormControl,
Validators,
} from '@angular/forms';
import { Router, ActivatedRoute, ParamMap } from '@angular/router';
import { Observable } from 'rxjs';
@Component({
selector: 'talkgroup-record',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, AlertRuleBuilderComponent],
templateUrl: './talkgroup-record.component.html',
styleUrl: './talkgroup-record.component.css',
})
export class TalkgroupRecordComponent {
tg!: Talkgroup;
tgService: TalkgroupService = inject(TalkgroupService);
form!: FormGroup;
constructor(
private route: ActivatedRoute,
private router: Router,
) {}
ngOnInit() {
const sysId = this.route.snapshot.paramMap.get('sys');
const tgId = this.route.snapshot.paramMap.get('tg');
this.tgService
.getTalkgroup(Number(sysId), Number(tgId))
.subscribe((data: Talkgroup) => {
this.tg = data;
});
this.form = new FormGroup({
name: new FormControl(''),
alpha_tag: new FormControl(''),
tg_group: new FormControl(''),
frequency: new FormControl(0),
alert: new FormControl(true),
weight: new FormControl(1.0),
});
}
submit() {
let tgu: TalkgroupUpdate = <TalkgroupUpdate>{
system_id: this.tg.system_id,
tgid: this.tg.tgid,
id: this.tg.id,
};
if (this.form.controls['name'].dirty) {
tgu.name = this.tg.name;
}
if (this.form.controls['alpha_tag'].dirty) {
tgu.alpha_tag = this.tg.alpha_tag;
}
if (this.form.controls['tg_group'].dirty) {
tgu.tg_group = this.tg.tg_group;
}
if (this.form.controls['frequency'].dirty) {
tgu.frequency = this.tg.frequency;
}
if (this.form.controls['alert'].dirty) {
tgu.alert = this.tg.alert;
}
if (this.form.controls['weight'].dirty) {
tgu.weight = Number(this.tg.weight);
}
this.tgService
.putTalkgroup(tgu)
.pipe(
catchError(() => {
return of(null);
}),
)
.subscribe((event) => {
this.router.navigate(['/talkgroups/']);
});
}
}

View file

@ -0,0 +1,31 @@
<a href="#" class="btn btn-primary" routerLink="/talkgroups/import">Import</a>
<div class="w-100 justify-center overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Sys</th>
<th>Sys ID</th>
<th>Name</th>
<th>TG ID</th>
<th>Learned</th>
<th></th>
</tr>
</thead>
<tbody>
@for (tg of talkgroups$ | async; track tg.id) {
<tr>
<td>{{ tg.system.name }}</td>
<td>{{ tg.system.id }}</td>
<td>{{ tg.name }}</td>
<td>{{ tg.tgid }}</td>
<td>{{ tg?.learned ? 'Y' : '' }}</td>
<td>
<a routerLink="/talkgroups/{{ tg.system.id }}/{{ tg.tgid }}"
><ng-icon name="ionCreateOutline"></ng-icon
></a>
</td>
</tr>
}
</tbody>
</table>
</div>

View file

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

View file

@ -0,0 +1,43 @@
import { Component, inject } from '@angular/core';
import { TalkgroupService } from './talkgroups.service';
import { Talkgroup } from '../talkgroup';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { ionCreateOutline } from '@ng-icons/ionicons';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { RouterModule, RouterOutlet, RouterLink } from '@angular/router';
import { CommonModule } from '@angular/common';
@Component({
selector: 'talkgroups',
standalone: true,
imports: [
NgIconComponent,
RouterOutlet,
RouterModule,
RouterLink,
CommonModule,
],
templateUrl: './talkgroups.component.html',
styleUrl: './talkgroups.component.css',
providers: [provideIcons({ ionCreateOutline })],
})
export class TalkgroupsComponent {
selectedSys: number = 0;
selectedId: number = 0;
talkgroups$!: Observable<Talkgroup[]>;
tgService: TalkgroupService = inject(TalkgroupService);
constructor(private route: ActivatedRoute) {}
ngOnInit() {
this.talkgroups$ = this.route.paramMap.pipe(
switchMap((params) => {
this.selectedSys = Number(params.get('sys'));
this.selectedId = Number(params.get('tg'));
return this.tgService.getTalkgroups();
}),
);
}
}

View file

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

View file

@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Talkgroup, TalkgroupUpdate } from '../talkgroup';
@Injectable({
providedIn: 'root',
})
export class TalkgroupService {
loggedIn: boolean = false;
constructor(private http: HttpClient) {}
getTalkgroups(): Observable<Talkgroup[]> {
return this.http.get<Talkgroup[]>('/api/talkgroup/');
}
getTalkgroup(sys: number, tg: number): Observable<Talkgroup> {
return this.http.get<Talkgroup>(`/api/talkgroup/${sys}/${tg}`);
}
importRR(sysID: number, content: string): Observable<Talkgroup[]> {
return this.http.post<Talkgroup[]>('/api/talkgroup/import',
{systemID: sysID, type: 'radioreference', body: content});
}
putTalkgroup(tu: TalkgroupUpdate): Observable<Talkgroup> {
return this.http.put<Talkgroup>(
`/api/talkgroup/${tu.system_id}/${tu.tgid}`,
tu,
);
}
}

Binary file not shown.

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<title>Admin</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>

7
client/admin/src/main.ts Normal file
View file

@ -0,0 +1,7 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig).catch((err) =>
console.error(err),
);

View file

@ -0,0 +1,9 @@
/* You can add global styles to this file, and also import other style files */
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "Warnes";
src: url("./assets/Warnes.ttf");
}

View file

@ -0,0 +1,18 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{html,ts}"],
theme: {
extend: {},
},
plugins: [require("daisyui")],
daisyui: {
themes: true,
styled: true,
themes: true,
base: true,
utils: true,
logs: true,
rtl: false,
},
};

View file

@ -0,0 +1,11 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"]
}

View file

@ -0,0 +1,30 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "dom"]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View file

@ -0,0 +1,10 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": ["jasmine"]
},
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
}

View file

@ -35,8 +35,5 @@ func main() {
cmds := append([]*cobra.Command{serve.Command(cfg)}, admin.Command(cfg)...)
rootCmd.AddCommand(cmds...)
err := rootCmd.Execute()
if err != nil {
log.Fatal().Err(err).Msg("Dying")
}
rootCmd.Execute()
}

View file

@ -41,3 +41,11 @@ notify:
# {{ end -}}
config:
webhookURL: "http://somewhere"
# configure upstream relays here
relay:
# `url` is the root of the instance
# - url: 'http://some.host:3051/'
# apiKey: aaaabbbb-cccc-dddd-eeee-ffff11112222
# `required` specifies whether we should report failure (i.e. HTTP 500 for rdio-http) to the source
# if the relay call submission fails
# required: false

44
go.mod
View file

@ -8,24 +8,24 @@ require (
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
github.com/go-chi/httprate v0.9.0
github.com/go-chi/httprate v0.14.1
github.com/go-chi/jwtauth/v5 v5.3.1
github.com/go-chi/render v1.0.3
github.com/go-viper/mapstructure/v2 v2.2.1
github.com/golang-migrate/migrate/v4 v4.17.1
github.com/golang-migrate/migrate/v4 v4.18.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/hajimehoshi/oto v1.0.1
github.com/jackc/pgx/v5 v5.6.0
github.com/nikoksr/notify v1.0.1
github.com/jackc/pgx/v5 v5.7.1
github.com/nikoksr/notify v1.1.0
github.com/rs/zerolog v1.33.0
github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.9.0
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300
golang.org/x/crypto v0.28.0
golang.org/x/sync v0.8.0
golang.org/x/term v0.25.0
google.golang.org/protobuf v1.35.1
golang.org/x/crypto v0.29.0
golang.org/x/sync v0.9.0
golang.org/x/term v0.26.0
google.golang.org/protobuf v1.35.2
gopkg.in/yaml.v3 v3.0.1
)
@ -33,35 +33,33 @@ require (
github.com/ajg/form v1.5.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
github.com/go-audio/audio v1.0.0 // indirect
github.com/go-audio/riff v1.0.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.4 // 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.0.20 // indirect
github.com/lestrrat-go/jwx/v2 v2.1.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.2 // indirect
go.uber.org/atomic v1.7.0 // indirect
golang.org/x/exp/shiny v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/image v0.14.0 // indirect
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.19.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
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/text v0.20.0 // indirect
)

131
go.sum
View file

@ -2,8 +2,8 @@ dynatron.me/x/go-minimp3 v0.0.0-20240805171536-7ea857e216d6 h1:04vlkvfe/mkY4CFpM
dynatron.me/x/go-minimp3 v0.0.0-20240805171536-7ea857e216d6/go.mod h1:zgYpOsy9VkbzyavLQARfhavkKybknoJyQz0pSBjnWwU=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
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=
@ -12,22 +12,23 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/dhui/dktest v0.4.1 h1:/w+IWuDXVymg3IrRJCHHOkMK10m9aNVMOyD0X12YVTg=
github.com/dhui/dktest v0.4.1/go.mod h1:DdOqcUpL7vgyP4GlF3X3w7HbSlz8cEQzwewPveYEQbA=
github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8=
github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0=
github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/dhui/dktest v0.4.3 h1:wquqUxAFdcUgabAVLvSCOKOlag5cIZuaOjYIBOWdsR0=
github.com/dhui/dktest v0.4.3/go.mod h1:zNK8IwktWzQRm6I/l2Wjp7MakiyaFWv4G1hjmodmMTs=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4=
github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs=
github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA=
@ -38,21 +39,25 @@ github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.9.0 h1:21A+4WDMDA5FyWcg7mNrhj63aNT8CGh+Z1alOE/piU8=
github.com/go-chi/httprate v0.9.0/go.mod h1:6GOYBSwnpra4CQfAKXu8sQZg+nZ0M1g9QnyFvxrAB8A=
github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs=
github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0=
github.com/go-chi/jwtauth/v5 v5.3.1 h1:1ePWrjVctvp1tyBq5b/2ER8Th/+RbYc7x4qNsc5rh5A=
github.com/go-chi/jwtauth/v5 v5.3.1/go.mod h1:6Fl2RRmWXs3tJYE1IQGX81FsPoGqDwq9c15j52R5q80=
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
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.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4=
github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM=
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/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=
@ -68,16 +73,16 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
@ -88,12 +93,12 @@ github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJGdI8=
github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
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.0.20 h1:sAgXuWS/t8ykxS9Bi2Qtn5Qhpakw1wrcjxChudjolCc=
github.com/lestrrat-go/jwx/v2 v2.0.20/go.mod h1:UlCSmKqw+agm5BsOBfEAbTvKsEApaGNqHAEUTv5PJC4=
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/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@ -105,16 +110,18 @@ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/nikoksr/notify v1.0.1 h1:HkUi4YHASwo3N8UEtDz9GRyEuGyX2Qwe9C6qKK24TYo=
github.com/nikoksr/notify v1.0.1/go.mod h1:w9zFImNfVM7l/gkKFAiyJITKzdC1GH458t4XqMkasZA=
github.com/nikoksr/notify v1.1.0 h1:IMw9p5ARDtKzZQmQSUy2+2LU/PPwBCdwQg2lCZh9EvY=
github.com/nikoksr/notify v1.1.0/go.mod h1:joe1r6qqAznTHzkC734Li8hxxVAYxzO6phBtMLfOVuo=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM=
github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -144,41 +151,43 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 h1:XQdibLKagjdevRB6vAjVY4qbSr8rQ610YzTkWcxzxSI=
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300/go.mod h1:FNa/dfN95vAYCNFrIKRrlRo+MBLbwmR9Asa5f2ljmBI=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys=
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.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=
golang.org/x/exp/shiny v0.0.0-20240719175910-8a7402abbf56 h1:8jM66xzUJjNInw31Y8bic4AYSLVChztDRT93+kmofUY=
golang.org/x/exp/shiny v0.0.0-20240719175910-8a7402abbf56/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o=
golang.org/x/exp/shiny v0.0.0-20241108190413-2d47ceb2692f h1:vic/+xALPgqKNmH4tJ2tsJh9M51ULiNg5hoQd84DVM4=
golang.org/x/exp/shiny v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g=
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-20231127183840-76ac6878050a h1:sYbmY3FwUWCBTodZL1S3JUuOvaW6kM2o+clDzzDNBWg=
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
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/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-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.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
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=
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=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View file

@ -1,19 +1,8 @@
package forms
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"reflect"
"strconv"
"strings"
"time"
"dynatron.me/x/stillbox/internal/jsontypes"
"github.com/araddon/dateparse"
)
var (
@ -80,293 +69,3 @@ func (o *options) Tag() string {
return "form"
}
func (o *options) parseTime(s string, dpo ...dateparse.ParserOption) (t time.Time, set bool, err error) {
if o.acceptBlank && s == "" {
set = false
return
}
if iv, err := strconv.Atoi(s); err == nil {
return time.Unix(int64(iv), 0), true, nil
}
switch {
case o.parseTimeIn != nil:
t, err = dateparse.ParseIn(s, o.parseTimeIn, dpo...)
case o.parseLocal:
t, err = dateparse.ParseLocal(s, dpo...)
default:
t, err = dateparse.ParseAny(s, dpo...)
}
set = true
return
}
func (o *options) parseBool(s string) (v bool, set bool, err error) {
if o.acceptBlank && s == "" {
set = false
return
}
set = true
v, err = strconv.ParseBool(s)
if err != nil {
return v, set, fmt.Errorf("parsebool('%s'): %w", s, err)
}
return
}
func (o *options) parseInt(s string) (v int, set bool, err error) {
if o.acceptBlank && s == "" {
set = false
return
}
set = true
v, err = strconv.Atoi(s)
if err != nil {
return v, set, fmt.Errorf("atoi('%s'): %w", s, err)
}
return
}
func (o *options) parseFloat64(s string) (v float64, set bool, err error) {
if o.acceptBlank && s == "" {
set = false
return
}
set = true
v, err = strconv.ParseFloat(s, 64)
if err != nil {
return v, set, fmt.Errorf("ParseFloat('%s'): %w", s, err)
}
return
}
func (o *options) parseDuration(s string) (v time.Duration, set bool, err error) {
if o.acceptBlank && s == "" {
set = false
return
}
set = true
v, err = time.ParseDuration(s)
if err != nil {
return v, set, fmt.Errorf("ParseDuration('%s'): %w", s, err)
}
return
}
var typeOfByteSlice = reflect.TypeOf([]byte(nil))
func (o *options) iterFields(r *http.Request, destStruct reflect.Value) error {
structType := destStruct.Type()
for i := 0; i < destStruct.NumField(); i++ {
destFieldVal := destStruct.Field(i)
fieldType := structType.Field(i)
if !fieldType.IsExported() && !fieldType.Anonymous {
continue
}
if destFieldVal.Kind() == reflect.Struct && fieldType.Anonymous {
err := o.iterFields(r, destFieldVal)
if err != nil {
return err
}
}
var tAr []string
var formField string
var omitEmpty bool
if o.defaultOmitEmpty {
omitEmpty = true
}
formTag, has := structType.Field(i).Tag.Lookup(o.Tag())
if has {
tAr = strings.Split(formTag, ",")
formField = tAr[0]
for _, v := range tAr[1:] {
if v == "omitempty" {
omitEmpty = true
break
}
}
}
if !has || formField == "-" {
continue
}
destFieldIntf := destFieldVal.Interface()
if destFieldVal.Kind() == reflect.Slice && destFieldVal.Type() == typeOfByteSlice {
file, hdr, err := r.FormFile(formField)
if err != nil {
return fmt.Errorf("get form file: %w", err)
}
nameField, hasFilename := structType.Field(i).Tag.Lookup("filenameField")
if hasFilename {
fnf := destStruct.FieldByName(nameField)
if fnf == (reflect.Value{}) {
panic(fmt.Errorf("filenameField '%s' does not exist", nameField))
}
fnf.SetString(hdr.Filename)
}
audioBytes, err := io.ReadAll(file)
if err != nil {
return fmt.Errorf("file read: %w", err)
}
destFieldVal.SetBytes(audioBytes)
continue
}
if !r.Form.Has(formField) && omitEmpty {
continue
}
ff := r.Form.Get(formField)
switch v := destFieldIntf.(type) {
case string, *string:
setVal(destFieldVal, ff != "" || o.acceptBlank, ff)
case int, uint, *int, *uint:
val, set, err := o.parseInt(ff)
if err != nil {
return err
}
setVal(destFieldVal, set, val)
case float64:
val, set, err := o.parseFloat64(ff)
if err != nil {
return err
}
setVal(destFieldVal, set, val)
case bool, *bool:
val, set, err := o.parseBool(ff)
if err != nil {
return err
}
setVal(destFieldVal, set, val)
case time.Time, *time.Time, jsontypes.Time, *jsontypes.Time:
t, set, err := o.parseTime(ff)
if err != nil {
return err
}
setVal(destFieldVal, set, t)
case time.Duration, *time.Duration, jsontypes.Duration, *jsontypes.Duration:
d, set, err := o.parseDuration(ff)
if err != nil {
return err
}
setVal(destFieldVal, set, d)
case []int:
val := strings.Trim(ff, "[]")
if val == "" && o.acceptBlank {
continue
}
vals := strings.Split(val, ",")
ar := make([]int, 0, len(vals))
for _, v := range vals {
i, err := strconv.Atoi(v)
if err == nil {
ar = append(ar, i)
}
}
destFieldVal.Set(reflect.ValueOf(ar))
default:
panic(fmt.Errorf("unsupported type %T", v))
}
}
return nil
}
func setVal(destFieldVal reflect.Value, set bool, src any) {
if !set {
return
}
destType := destFieldVal.Type()
srcVal := reflect.ValueOf(src)
if srcVal.Kind() == reflect.Ptr {
srcVal = srcVal.Elem()
}
if destType.Kind() == reflect.Ptr {
if !srcVal.CanAddr() {
if srcVal.CanConvert(destType.Elem()) {
srcVal = srcVal.Convert(destType.Elem())
}
copy := reflect.New(srcVal.Type())
copy.Elem().Set(srcVal)
srcVal = copy
}
} else if srcVal.CanConvert(destFieldVal.Type()) {
srcVal = srcVal.Convert(destFieldVal.Type())
}
destFieldVal.Set(srcVal)
}
func Unmarshal(r *http.Request, dest any, opt ...Option) error {
o := options{
maxMultipartMemory: MaxMultipartMemory,
}
for _, opt := range opt {
opt(&o)
}
contentType := strings.Split(r.Header.Get("Content-Type"), ";")[0]
switch contentType {
case "multipart/form-data":
err := r.ParseMultipartForm(o.maxMultipartMemory)
if err != nil {
return fmt.Errorf("ParseForm: %w", err)
}
return o.unmarshalForm(r, dest)
case "application/x-www-form-urlencoded":
err := r.ParseForm()
if err != nil {
return fmt.Errorf("ParseForm: %w", err)
}
return o.unmarshalForm(r, dest)
case "application/json":
return json.NewDecoder(r.Body).Decode(dest)
}
return ErrContentType
}
func (o *options) unmarshalForm(r *http.Request, dest any) error {
destVal := reflect.ValueOf(dest)
if k := destVal.Kind(); k == reflect.Ptr {
destVal = destVal.Elem()
} else {
return ErrNotPointer
}
if destVal.Kind() != reflect.Struct {
return ErrNotStruct
}
return o.iterFields(r, destVal)
}

139
internal/forms/marshal.go Normal file
View file

@ -0,0 +1,139 @@
package forms
import (
"fmt"
"mime/multipart"
"reflect"
"strconv"
"strings"
"time"
)
func Marshal(src any, dest *multipart.Writer, opts ...Option) error {
o := options{}
for _, opt := range opts {
opt(&o)
}
return o.marshalMultipartForm(src, dest)
}
func (o *options) marshalMultipartForm(src any, dest *multipart.Writer) error {
srcVal := reflect.ValueOf(src)
if k := srcVal.Kind(); k == reflect.Ptr {
srcVal = srcVal.Elem()
}
if srcVal.Kind() != reflect.Struct {
return ErrNotStruct
}
return o.marIterFields(srcVal, dest)
}
func (o *options) marIterFields(srcVal reflect.Value, dest *multipart.Writer) error {
structType := srcVal.Type()
for i := 0; i < structType.NumField(); i++ {
srcFieldVal := srcVal.Field(i)
fieldType := structType.Field(i)
if !fieldType.IsExported() && !fieldType.Anonymous {
continue
}
if srcFieldVal.Kind() == reflect.Struct && fieldType.Anonymous {
continue
}
var tAr []string
var formField string
var omitEmpty bool
if o.defaultOmitEmpty {
omitEmpty = true
}
formTag, has := structType.Field(i).Tag.Lookup(o.Tag())
if has {
tAr = strings.Split(formTag, ",")
formField = tAr[0]
for _, v := range tAr[1:] {
if v == "omitempty" {
omitEmpty = true
break
}
}
}
if !has || formField == "-" {
continue
}
if srcFieldVal.Kind() == reflect.Ptr {
srcFieldVal = srcFieldVal.Elem()
if srcFieldVal == (reflect.Value{}) || srcFieldVal.IsZero() {
continue
}
}
srcFieldIntf := srcFieldVal.Interface()
if srcFieldVal.Kind() == reflect.Slice && srcFieldVal.Type() == typeOfByteSlice {
nameField, hasFilename := structType.Field(i).Tag.Lookup("filenameField")
fileName := ""
if hasFilename {
fnf := srcVal.FieldByName(nameField)
if fnf == (reflect.Value{}) {
panic(fmt.Errorf("filenameField '%s' does not exist", nameField))
}
fileName = fnf.String()
}
fw, err := dest.CreateFormFile(formField, fileName)
if err != nil {
return fmt.Errorf("form marshal: createFormFile: %w", err)
}
_, err = fw.Write(srcFieldVal.Bytes())
if err != nil {
return fmt.Errorf("form marshal: write file: %w", err)
}
continue
}
if srcFieldVal.IsZero() && omitEmpty {
continue
}
var val string
switch v := srcFieldIntf.(type) {
case []string:
if omitEmpty && len(v) == 0 {
continue
}
val = "[" + strings.Join(v, ",") + "]"
case []int:
if omitEmpty && len(v) == 0 {
continue
}
sl := make([]string, len(v))
for i := range v {
sl[i] = strconv.Itoa(v[i])
}
val = "[" + strings.Join(sl, ",") + "]"
case time.Time:
val = strconv.Itoa(int(v.Unix()))
default:
val = fmt.Sprint(v)
}
err := dest.WriteField(formField, val)
if err != nil {
return fmt.Errorf("marshal field '%s': %w", formField, err)
}
}
return nil
}

View file

@ -0,0 +1,121 @@
package forms_test
import (
"bytes"
"fmt"
"math/rand"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/pkg/auth"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/sources"
"github.com/google/uuid"
)
type hand func(w http.ResponseWriter, r *http.Request)
func (h hand) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h(w, r)
}
func call(url string, call *calls.Call) error {
var buf bytes.Buffer
body := multipart.NewWriter(&buf)
err := forms.Marshal(call, body)
if err != nil {
return fmt.Errorf("relay form parse: %w", err)
}
body.Close()
r, err := http.NewRequest(http.MethodPost, url, &buf)
if err != nil {
return fmt.Errorf("relay newrequest: %w", err)
}
r.Header.Set("Content-Type", body.FormDataContentType())
resp, err := http.DefaultClient.Do(r)
if err != nil {
return fmt.Errorf("relay: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("relay: received HTTP %d", resp.StatusCode)
}
return nil
}
func TestMarshal(t *testing.T) {
uuid.SetRand(rand.New(rand.NewSource(1)))
tests := []struct {
name string
submitter auth.UserID
apiKey string
call calls.Call
}{
{
name: "base",
submitter: auth.UserID(1),
call: calls.Call{
ID: uuid.UUID([16]byte{0x52, 0xfd, 0xfc, 0x07, 0x21, 0x82, 0x45, 0x4f, 0x96, 0x3f, 0x5f, 0x0f, 0x9a, 0x62, 0x1d, 0x72}),
Submitter: common.PtrTo(auth.UserID(1)),
System: 197,
Talkgroup: 10101,
DateTime: time.Date(2024, 11, 10, 23, 33, 02, 0, time.Local),
AudioName: "rightnow.mp3",
Audio: []byte{0xFF, 0xF3, 0x14, 0xC4, 0x00, 0x00, 0x00, 0x03, 0x48, 0x01, 0x40, 0x00, 0x00, 0x4C, 0x41, 0x4D, 0x45, 0x33, 0x2E, 0x39, 0x36, 0x2E, 0x31, 0x55},
AudioType: "audio/mpeg",
Duration: calls.CallDuration(24000000),
TalkgroupLabel: common.PtrTo("Some TG"),
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var serr error
var called bool
h := hand(func(w http.ResponseWriter, r *http.Request) {
called = true
serr = r.ParseMultipartForm(1024 * 1024 * 2)
if serr != nil {
t.Log("parsemultipart", serr)
return
}
cur := new(sources.CallUploadRequest)
serr = forms.Unmarshal(r, cur, forms.WithAcceptBlank())
cur.DontStore = true
if serr != nil {
t.Log("unmarshal", serr)
return
}
assert.Equal(t, tc.apiKey, cur.Key)
toC, tcerr := cur.ToCall(tc.submitter)
require.NoError(t, tcerr)
assert.Equal(t, &tc.call, toC)
})
svr := httptest.NewServer(h)
err := call(svr.URL, &tc.call)
assert.True(t, called)
assert.NoError(t, err)
assert.NoError(t, serr)
})
}
}

306
internal/forms/unmarshal.go Normal file
View file

@ -0,0 +1,306 @@
package forms
import (
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"strconv"
"strings"
"time"
"dynatron.me/x/stillbox/internal/jsontypes"
"github.com/araddon/dateparse"
)
func (o *options) parseTime(s string, dpo ...dateparse.ParserOption) (t time.Time, set bool, err error) {
if o.acceptBlank && s == "" {
set = false
return
}
if iv, err := strconv.Atoi(s); err == nil {
return time.Unix(int64(iv), 0), true, nil
}
switch {
case o.parseTimeIn != nil:
t, err = dateparse.ParseIn(s, o.parseTimeIn, dpo...)
case o.parseLocal:
t, err = dateparse.ParseLocal(s, dpo...)
default:
t, err = dateparse.ParseAny(s, dpo...)
}
set = true
return
}
func (o *options) parseBool(s string) (v bool, set bool, err error) {
if o.acceptBlank && s == "" {
set = false
return
}
set = true
v, err = strconv.ParseBool(s)
if err != nil {
return v, set, fmt.Errorf("parsebool('%s'): %w", s, err)
}
return
}
func (o *options) parseInt(s string) (v int, set bool, err error) {
if o.acceptBlank && s == "" {
set = false
return
}
set = true
v, err = strconv.Atoi(s)
if err != nil {
return v, set, fmt.Errorf("atoi('%s'): %w", s, err)
}
return
}
func (o *options) parseFloat64(s string) (v float64, set bool, err error) {
if o.acceptBlank && s == "" {
set = false
return
}
set = true
v, err = strconv.ParseFloat(s, 64)
if err != nil {
return v, set, fmt.Errorf("ParseFloat('%s'): %w", s, err)
}
return
}
func (o *options) parseDuration(s string) (v time.Duration, set bool, err error) {
if o.acceptBlank && s == "" {
set = false
return
}
set = true
v, err = time.ParseDuration(s)
if err != nil {
return v, set, fmt.Errorf("ParseDuration('%s'): %w", s, err)
}
return
}
var typeOfByteSlice = reflect.TypeOf([]byte(nil))
func (o *options) unmIterFields(r *http.Request, destStruct reflect.Value) error {
structType := destStruct.Type()
for i := 0; i < destStruct.NumField(); i++ {
destFieldVal := destStruct.Field(i)
fieldType := structType.Field(i)
if !fieldType.IsExported() && !fieldType.Anonymous {
continue
}
if destFieldVal.Kind() == reflect.Struct && fieldType.Anonymous {
err := o.unmIterFields(r, destFieldVal)
if err != nil {
return err
}
}
var tAr []string
var formField string
var omitEmpty bool
if o.defaultOmitEmpty {
omitEmpty = true
}
formTag, has := structType.Field(i).Tag.Lookup(o.Tag())
if has {
tAr = strings.Split(formTag, ",")
formField = tAr[0]
for _, v := range tAr[1:] {
if v == "omitempty" {
omitEmpty = true
break
}
}
}
if !has || formField == "-" {
continue
}
destFieldIntf := destFieldVal.Interface()
if destFieldVal.Kind() == reflect.Slice && destFieldVal.Type() == typeOfByteSlice {
file, hdr, err := r.FormFile(formField)
if err != nil {
return fmt.Errorf("get form file: %w", err)
}
nameField, hasFilename := structType.Field(i).Tag.Lookup("filenameField")
if hasFilename {
fnf := destStruct.FieldByName(nameField)
if fnf == (reflect.Value{}) {
panic(fmt.Errorf("filenameField '%s' does not exist", nameField))
}
fnf.SetString(hdr.Filename)
}
audioBytes, err := io.ReadAll(file)
if err != nil {
return fmt.Errorf("file read: %w", err)
}
destFieldVal.SetBytes(audioBytes)
continue
}
if !r.Form.Has(formField) && omitEmpty {
continue
}
ff := r.Form.Get(formField)
switch v := destFieldIntf.(type) {
case string, *string:
setVal(destFieldVal, ff != "" || o.acceptBlank, ff)
case int, uint, *int, *uint:
val, set, err := o.parseInt(ff)
if err != nil {
return err
}
setVal(destFieldVal, set, val)
case float64:
val, set, err := o.parseFloat64(ff)
if err != nil {
return err
}
setVal(destFieldVal, set, val)
case bool, *bool:
val, set, err := o.parseBool(ff)
if err != nil {
return err
}
setVal(destFieldVal, set, val)
case time.Time, *time.Time, jsontypes.Time, *jsontypes.Time:
t, set, err := o.parseTime(ff)
if err != nil {
return err
}
setVal(destFieldVal, set, t)
case time.Duration, *time.Duration, jsontypes.Duration, *jsontypes.Duration:
d, set, err := o.parseDuration(ff)
if err != nil {
return err
}
setVal(destFieldVal, set, d)
case []int:
val := strings.Trim(ff, "[]")
if val == "" && o.acceptBlank {
continue
}
vals := strings.Split(val, ",")
ar := make([]int, 0, len(vals))
for _, v := range vals {
i, err := strconv.Atoi(v)
if err == nil {
ar = append(ar, i)
}
}
destFieldVal.Set(reflect.ValueOf(ar))
default:
panic(fmt.Errorf("unsupported type %T", v))
}
}
return nil
}
func setVal(destFieldVal reflect.Value, set bool, src any) {
if !set {
return
}
destType := destFieldVal.Type()
srcVal := reflect.ValueOf(src)
if srcVal.Kind() == reflect.Ptr {
srcVal = srcVal.Elem()
}
if destType.Kind() == reflect.Ptr {
if !srcVal.CanAddr() {
if srcVal.CanConvert(destType.Elem()) {
srcVal = srcVal.Convert(destType.Elem())
}
copy := reflect.New(srcVal.Type())
copy.Elem().Set(srcVal)
srcVal = copy
}
} else if srcVal.CanConvert(destFieldVal.Type()) {
srcVal = srcVal.Convert(destFieldVal.Type())
}
destFieldVal.Set(srcVal)
}
func Unmarshal(r *http.Request, dest any, opts ...Option) error {
o := options{
maxMultipartMemory: MaxMultipartMemory,
}
for _, opt := range opts {
opt(&o)
}
contentType := strings.Split(r.Header.Get("Content-Type"), ";")[0]
switch contentType {
case "multipart/form-data":
err := r.ParseMultipartForm(o.maxMultipartMemory)
if err != nil {
return fmt.Errorf("ParseForm: %w", err)
}
return o.unmarshalForm(r, dest)
case "application/x-www-form-urlencoded":
err := r.ParseForm()
if err != nil {
return fmt.Errorf("ParseForm: %w", err)
}
return o.unmarshalForm(r, dest)
case "application/json":
return json.NewDecoder(r.Body).Decode(dest)
}
return ErrContentType
}
func (o *options) unmarshalForm(r *http.Request, dest any) error {
destVal := reflect.ValueOf(dest)
if k := destVal.Kind(); k == reflect.Ptr {
destVal = destVal.Elem()
} else {
return ErrNotPointer
}
if destVal.Kind() != reflect.Struct {
return ErrNotStruct
}
return o.unmIterFields(r, destVal)
}

View file

@ -10,12 +10,11 @@ import (
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/talkgroups"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type Alert struct {
ID uuid.UUID
ID int
Timestamp time.Time
TGName string
Score trending.Score[talkgroups.ID]
@ -34,7 +33,6 @@ func (a *Alert) ToAddAlertParams() database.AddAlertParams {
}
return database.AddAlertParams{
ID: a.ID,
Time: pgtype.Timestamptz{Time: a.Timestamp, Valid: true},
SystemID: int(a.Score.ID.System),
TGID: int(a.Score.ID.Talkgroup),
@ -48,7 +46,6 @@ func (a *Alert) ToAddAlertParams() database.AddAlertParams {
// Make creates an alert for later rendering or storage.
func Make(ctx context.Context, store talkgroups.Store, score trending.Score[talkgroups.ID], origScore float64) (Alert, error) {
d := Alert{
ID: uuid.New(),
Score: score,
Timestamp: time.Now(),
Weight: 1.0,

View file

@ -161,7 +161,7 @@ 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 || !tgr.Talkgroup.Alert {
continue
}

View file

@ -40,7 +40,8 @@ func (as *alerter) tgStatsHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
db := database.FromCtx(ctx)
tgs, err := db.GetTalkgroupsWithLearnedBySysTGID(ctx, as.scoredTGsTuple())
tgt := as.scoredTGsTuple()
tgs, err := db.GetTalkgroupsWithLearnedBySysTGID(ctx, tgt)
if err != nil {
log.Error().Err(err).Msg("stats TG get failed")
http.Error(w, err.Error(), http.StatusInternalServerError)

View file

@ -98,7 +98,7 @@ func (a *Auth) Login(ctx context.Context, username, password string) (token stri
return a.newToken(found.ID), nil
}
func (a *Auth) newToken(uid int32) string {
func (a *Auth) newToken(uid int) string {
claims := claims{
"sub": strconv.Itoa(int(uid)),
}
@ -134,7 +134,7 @@ func (a *Auth) routeRefresh(w http.ResponseWriter, r *http.Request) {
return
}
tok := a.newToken(int32(uid))
tok := a.newToken(uid)
cookie := &http.Cookie{
Name: "jwt",

View file

@ -1,11 +1,13 @@
package calls
import (
"context"
"fmt"
"time"
"dynatron.me/x/stillbox/internal/audio"
"dynatron.me/x/stillbox/pkg/auth"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/pb"
"dynatron.me/x/stillbox/pkg/talkgroups"
@ -33,26 +35,26 @@ func (d CallDuration) Seconds() int32 {
}
type Call struct {
ID uuid.UUID
Audio []byte
AudioName string
AudioType string
Duration CallDuration
DateTime time.Time
Frequencies []int
Frequency int
Patches []int
Source int
Sources []int
System int
Submitter *auth.UserID
SystemLabel string
Talkgroup int
TalkgroupGroup *string
TalkgroupLabel *string
TGAlphaTag *string
ID uuid.UUID `form:"-"`
Audio []byte `form:"audio" filenameField:"AudioName"`
AudioName string `form:"audioName"`
AudioType string `form:"audioType"`
Duration CallDuration `form:"-"`
DateTime time.Time `form:"dateTime"`
Frequencies []int `form:"frequencies"`
Frequency int `form:"frequency"`
Patches []int `form:"patches"`
Source int `form:"source"`
Sources []int `form:"sources"`
System int `form:"system"`
Submitter *auth.UserID `form:"-"`
SystemLabel string `form:"systemLabel"`
Talkgroup int `form:"talkgroup"`
TalkgroupGroup *string `form:"talkgroupGroup"`
TalkgroupLabel *string `form:"talkgroupLabel"`
TGAlphaTag *string `form:"talkgroupTag"` // not 1:1
shouldStore bool
shouldStore bool `form:"-"`
}
func (c *Call) String() string {
@ -111,6 +113,21 @@ func (c *Call) ToPB() *pb.Call {
}
}
func (c *Call) LearnTG(ctx context.Context, db database.Store) (learnedId int, err error) {
err = db.AddTalkgroupWithLearnedFlag(ctx, int32(c.System), int32(c.Talkgroup))
if err != nil {
return 0, fmt.Errorf("addTalkgroupWithLearnedFlag: %w", err)
}
return db.AddLearnedTalkgroup(ctx, database.AddLearnedTalkgroupParams{
SystemID: c.System,
TGID: c.Talkgroup,
Name: c.TalkgroupLabel,
AlphaTag: c.TGAlphaTag,
TGGroup: c.TalkgroupGroup,
})
}
func (c *Call) computeLength() (err error) {
var td time.Duration

View file

@ -22,6 +22,7 @@ type Config struct {
Public bool `yaml:"public"`
RateLimit RateLimit `yaml:"rateLimit"`
Notify Notify `yaml:"notify"`
Relay []Relay `yaml:"relay"`
configPath string
}
@ -63,6 +64,12 @@ type Alerting struct {
Renotify *jsontypes.Duration `yaml:"renotify,omitempty" form:"renotify,omitempty"`
}
type Relay struct {
URL string `yaml:"url"`
APIKey string `yaml:"apiKey"`
Required bool `yaml:"required"`
}
type Notify []NotifyService
type NotifyService struct {
@ -72,17 +79,6 @@ type NotifyService struct {
Config map[string]interface{} `yaml:"config" json:"config"`
}
func (n *NotifyService) GetS(k, defaultVal string) string {
if v, has := n.Config[k]; has {
if v, isString := v.(string); isString {
return v
}
log.Error().Str("configKey", k).Str("provider", n.Provider).Str("default", defaultVal).Msg("notify config value is not a string! using default")
}
return defaultVal
}
func (rl *RateLimit) Verify() bool {
if rl.Enable {
if rl.Requests > 0 && rl.Over > 0 {

View file

@ -13,7 +13,7 @@ import (
)
const addAlert = `-- name: AddAlert :exec
INSERT INTO alerts (id, time, tgid, system_id, weight, score, orig_score, notified, metadata)
INSERT INTO alerts (time, tgid, system_id, weight, score, orig_score, notified, metadata)
VALUES
(
$1,
@ -23,13 +23,11 @@ VALUES
$5,
$6,
$7,
$8,
$9
$8
)
`
type AddAlertParams struct {
ID uuid.UUID `json:"id"`
Time pgtype.Timestamptz `json:"time"`
TGID int `json:"tgid"`
SystemID int `json:"system_id"`
@ -42,7 +40,6 @@ type AddAlertParams struct {
func (q *Queries) AddAlert(ctx context.Context, arg AddAlertParams) error {
_, err := q.db.Exec(ctx, addAlert,
arg.ID,
arg.Time,
arg.TGID,
arg.SystemID,
@ -109,9 +106,9 @@ type AddCallParams struct {
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:"tg_label"`
TGAlphaTag *string `json:"tg_alpha_tag"`
TGGroup *string `json:"tg_group"`
Source int `json:"source"`
}
@ -130,9 +127,9 @@ func (q *Queries) AddCall(ctx context.Context, arg AddCallParams) error {
arg.Frequency,
arg.Frequencies,
arg.Patches,
arg.TgLabel,
arg.TgAlphaTag,
arg.TgGroup,
arg.TGLabel,
arg.TGAlphaTag,
arg.TGGroup,
arg.Source,
)
return err

View file

@ -3,6 +3,7 @@ package database
import (
"context"
"errors"
"fmt"
"strings"
"dynatron.me/x/stillbox/pkg/config"
@ -19,11 +20,12 @@ import (
// DB is a database handle.
//go:generate mockery
type DB interface {
type Store interface {
Querier
talkgroupQuerier
DB() *Database
InTx(context.Context, func(Store) error, pgx.TxOptions) error
}
type Database struct {
@ -35,14 +37,41 @@ func (db *Database) DB() *Database {
return db
}
func (db *Database) InTx(ctx context.Context, f func(Store) error, opts pgx.TxOptions) error {
tx, err := db.DB().Pool.BeginTx(ctx, opts)
if err != nil {
return fmt.Errorf("Tx begin: %w", err)
}
defer tx.Rollback(ctx)
dbtx := &Database{Pool: db.Pool, Queries: db.Queries.WithTx(tx)}
err = f(dbtx)
if err != nil {
return fmt.Errorf("Tx: %w", err)
}
err = tx.Commit(ctx)
if err != nil {
return fmt.Errorf("Tx commit: %w", err)
}
return nil
}
type dbLogger struct{}
func (m dbLogger) Log(ctx context.Context, level tracelog.LogLevel, msg string, data map[string]any) {
log.Debug().Fields(data).Msg(msg)
}
func Close(c Store) {
c.(*Database).Pool.Close()
}
// NewClient creates a new DB using the provided config.
func NewClient(ctx context.Context, conf config.DB) (DB, error) {
func NewClient(ctx context.Context, conf config.DB) (Store, error) {
dir, err := iofs.New(sqlembed.Migrations, "postgres/migrations")
if err != nil {
return nil, err
@ -90,8 +119,8 @@ type dBCtxKey string
const DBCtxKey dBCtxKey = "dbctx"
// FromCtx returns the database handle from the provided Context.
func FromCtx(ctx context.Context) DB {
c, ok := ctx.Value(DBCtxKey).(DB)
func FromCtx(ctx context.Context) Store {
c, ok := ctx.Value(DBCtxKey).(Store)
if !ok {
panic("no DB in context")
}
@ -100,7 +129,7 @@ func FromCtx(ctx context.Context) DB {
}
// CtxWithDB returns a Context with the provided database handle.
func CtxWithDB(ctx context.Context, conn DB) context.Context {
func CtxWithDB(ctx context.Context, conn Store) context.Context {
return context.WithValue(ctx, DBCtxKey, conn)
}

View file

@ -2,16 +2,16 @@ package database
func (d GetTalkgroupsRow) GetTalkgroup() Talkgroup { return d.Talkgroup }
func (d GetTalkgroupsRow) GetSystem() System { return d.System }
func (d GetTalkgroupsRow) GetLearned() bool { return d.Learned }
func (d GetTalkgroupsRow) GetLearned() bool { return d.Talkgroup.Learned }
func (g GetTalkgroupWithLearnedRow) GetTalkgroup() Talkgroup { return g.Talkgroup }
func (g GetTalkgroupWithLearnedRow) GetSystem() System { return g.System }
func (g GetTalkgroupWithLearnedRow) GetLearned() bool { return g.Learned }
func (g GetTalkgroupWithLearnedRow) GetLearned() bool { return g.Talkgroup.Learned }
func (g GetTalkgroupsWithLearnedRow) GetTalkgroup() Talkgroup { return g.Talkgroup }
func (g GetTalkgroupsWithLearnedRow) GetSystem() System { return g.System }
func (g GetTalkgroupsWithLearnedRow) GetLearned() bool { return g.Learned }
func (g GetTalkgroupsWithLearnedRow) GetLearned() bool { return g.Talkgroup.Learned }
func (g GetTalkgroupsWithLearnedBySystemRow) GetTalkgroup() Talkgroup { return g.Talkgroup }
func (g GetTalkgroupsWithLearnedBySystemRow) GetSystem() System { return g.System }
func (g GetTalkgroupsWithLearnedBySystemRow) GetLearned() bool { return g.Learned }
func (g GetTalkgroupsWithLearnedBySystemRow) GetLearned() bool { return g.Talkgroup.Learned }
func (g Talkgroup) GetTalkgroup() Talkgroup { return g }
func (g Talkgroup) GetSystem() System { return System{ID: int(g.SystemID)} }
func (g Talkgroup) GetLearned() bool { return false }

File diff suppressed because it is too large Load diff

1786
pkg/database/mocks/Store.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,7 @@ import (
)
type Alert struct {
ID uuid.UUID `json:"id"`
ID int `json:"id"`
Time pgtype.Timestamptz `json:"time"`
TGID int `json:"tgid"`
SystemID int `json:"system_id"`
@ -26,7 +26,7 @@ type Alert struct {
}
type ApiKey struct {
ID int32 `json:"id"`
ID int `json:"id"`
Owner int `json:"owner"`
CreatedAt time.Time `json:"created_at"`
Expires pgtype.Timestamp `json:"expires"`
@ -48,9 +48,9 @@ type Call struct {
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:"tg_label"`
TGAlphaTag *string `json:"tg_alpha_tag"`
TGGroup *string `json:"tg_group"`
Source int `json:"source"`
Transcript *string `json:"transcript"`
}
@ -83,31 +83,33 @@ type System struct {
}
type Talkgroup struct {
ID uuid.UUID `json:"id"`
ID int `json:"id"`
SystemID int32 `json:"system_id"`
TGID int32 `json:"tgid"`
Name *string `json:"name"`
AlphaTag *string `json:"alpha_tag"`
TgGroup *string `json:"tg_group"`
TGGroup *string `json:"tg_group"`
Frequency *int32 `json:"frequency"`
Metadata jsontypes.Metadata `json:"metadata"`
Tags []string `json:"tags"`
Alert bool `json:"alert"`
AlertConfig rules.AlertRules `json:"alert_config"`
Weight float32 `json:"weight"`
Learned bool `json:"learned"`
}
type TalkgroupsLearned struct {
ID int32 `json:"id"`
ID int `json:"id"`
SystemID int `json:"system_id"`
TGID int `json:"tgid"`
Name string `json:"name"`
AlphaTag *string `json:"alpha_tag"`
TGGroup *string `json:"tg_group"`
Ignored *bool `json:"ignored"`
}
type User struct {
ID int32 `json:"id"`
ID int `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`

View file

@ -14,6 +14,8 @@ import (
type Querier interface {
AddAlert(ctx context.Context, arg AddAlertParams) error
AddCall(ctx context.Context, arg AddCallParams) error
AddLearnedTalkgroup(ctx context.Context, arg AddLearnedTalkgroupParams) (int, error)
AddTalkgroupWithLearnedFlag(ctx context.Context, systemID int32, tGID int32) error
CreateAPIKey(ctx context.Context, owner int, expires pgtype.Timestamp, disabled *bool) (ApiKey, error)
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
DeleteAPIKey(ctx context.Context, apiKey string) error
@ -21,20 +23,20 @@ type Querier interface {
GetAPIKey(ctx context.Context, apiKey string) (ApiKey, error)
GetDatabaseSize(ctx context.Context) (string, error)
GetSystemName(ctx context.Context, systemID int) (string, error)
GetTalkgroup(ctx context.Context, systemID int32, tgID int32) (GetTalkgroupRow, error)
GetTalkgroup(ctx context.Context, systemID int32, tGID int32) (GetTalkgroupRow, error)
GetTalkgroupIDsByTags(ctx context.Context, anyTags []string, allTags []string, notTags []string) ([]GetTalkgroupIDsByTagsRow, error)
GetTalkgroupTags(ctx context.Context, systemID int32, tgID int32) ([]string, error)
GetTalkgroupTags(ctx context.Context, systemID int32, tGID int32) ([]string, error)
GetTalkgroupWithLearned(ctx context.Context, systemID int32, tGID int32) (GetTalkgroupWithLearnedRow, error)
GetTalkgroupsWithAllTags(ctx context.Context, tags []string) ([]GetTalkgroupsWithAllTagsRow, error)
GetTalkgroupsWithAnyTags(ctx context.Context, tags []string) ([]GetTalkgroupsWithAnyTagsRow, error)
GetTalkgroupsWithLearned(ctx context.Context) ([]GetTalkgroupsWithLearnedRow, error)
GetTalkgroupsWithLearnedBySystem(ctx context.Context, system int32) ([]GetTalkgroupsWithLearnedBySystemRow, error)
GetUserByID(ctx context.Context, id int32) (User, error)
GetUserByUID(ctx context.Context, id int32) (User, error)
GetUserByID(ctx context.Context, id int) (User, error)
GetUserByUID(ctx context.Context, id int) (User, error)
GetUserByUsername(ctx context.Context, username string) (User, error)
GetUsers(ctx context.Context) ([]User, error)
SetCallTranscript(ctx context.Context, iD uuid.UUID, transcript *string) error
SetTalkgroupTags(ctx context.Context, tags []string, systemID int32, tgID int32) error
SetTalkgroupTags(ctx context.Context, tags []string, systemID int32, tGID int32) error
UpdatePassword(ctx context.Context, username string, password string) error
UpdateTalkgroup(ctx context.Context, arg UpdateTalkgroupParams) (Talkgroup, error)
}

View file

@ -2,6 +2,9 @@ package database
import (
"context"
"errors"
"github.com/jackc/pgx/v5/pgconn"
)
type talkgroupQuerier interface {
@ -12,6 +15,17 @@ type talkgroupQuerier interface {
type TGTuples [2][]uint32
const TGConstraintName = "calls_system_talkgroup_fkey"
func IsTGConstraintViolation(e error) bool {
var err *pgconn.PgError
if errors.As(e, &err) && err.Code == "23503" && err.ConstraintName == TGConstraintName {
return true
}
return false
}
func MakeTGTuples(cap int) TGTuples {
return [2][]uint32{
make([]uint32, 0, cap),
@ -27,18 +41,17 @@ func (t *TGTuples) Append(sys, tg uint32) {
// Below queries are here because sqlc refuses to parse unnest(x, y)
const getTalkgroupsWithLearnedBySysTGID = `SELECT
tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, sys.id, sys.name,
FALSE learned
tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, sys.id, sys.name, tg.learned
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
JOIN UNNEST($1::INT4[], $2::INT4[]) AS tgt(sys, tg) ON (tg.system_id = tgt.sys AND tg.tgid = tgt.tg)
WHERE tg.learned IS NOT TRUE
UNION
SELECT
NULL::UUID, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.alpha_tag, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END,
TRUE, NULL::JSONB, 1.0, sys.id, sys.name,
TRUE learned
tgl.id, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.tg_group, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.tg_group IS NULL THEN NULL ELSE ARRAY[tgl.tg_group] END,
NOT tgl.ignored, NULL::JSONB, 1.0, sys.id, sys.name, TRUE learned
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
JOIN UNNEST($1::INT4[], $2::INT4[]) AS tgt(sys, tg) ON (tgl.system_id = tgt.sys AND tgl.tgid = tgt.tg);`
@ -46,7 +59,6 @@ JOIN UNNEST($1::INT4[], $2::INT4[]) AS tgt(sys, tg) ON (tgl.system_id = tgt.sys
type GetTalkgroupsRow struct {
Talkgroup Talkgroup `json:"talkgroup"`
System System `json:"system"`
Learned bool `json:"learned"`
}
func (q *Queries) GetTalkgroupsWithLearnedBySysTGID(ctx context.Context, ids TGTuples) ([]GetTalkgroupsRow, error) {
@ -64,7 +76,7 @@ func (q *Queries) GetTalkgroupsWithLearnedBySysTGID(ctx context.Context, ids TGT
&i.Talkgroup.TGID,
&i.Talkgroup.Name,
&i.Talkgroup.AlphaTag,
&i.Talkgroup.TgGroup,
&i.Talkgroup.TGGroup,
&i.Talkgroup.Frequency,
&i.Talkgroup.Metadata,
&i.Talkgroup.Tags,
@ -73,7 +85,7 @@ func (q *Queries) GetTalkgroupsWithLearnedBySysTGID(ctx context.Context, ids TGT
&i.Talkgroup.Weight,
&i.System.ID,
&i.System.Name,
&i.Learned,
&i.Talkgroup.Learned,
); err != nil {
return nil, err
}
@ -87,7 +99,8 @@ func (q *Queries) GetTalkgroupsWithLearnedBySysTGID(ctx context.Context, ids TGT
const getTalkgroupsBySysTGID = `SELECT tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, sys.id, sys.name FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
JOIN UNNEST($1::INT4[], $2::INT4[]) AS tgt(sys, tg) ON (tg.system_id = tgt.sys AND tg.tgid = tgt.tg);`
JOIN UNNEST($1::INT4[], $2::INT4[]) AS tgt(sys, tg) ON (tg.system_id = tgt.sys AND tg.tgid = tgt.tg)
WHERE tg.learned IS NOT TRUE;`
func (q *Queries) GetTalkgroupsBySysTGID(ctx context.Context, ids TGTuples) ([]GetTalkgroupsRow, error) {
rows, err := q.db.Query(ctx, getTalkgroupsBySysTGID, ids[0], ids[1])
@ -104,7 +117,7 @@ func (q *Queries) GetTalkgroupsBySysTGID(ctx context.Context, ids TGTuples) ([]G
&i.Talkgroup.TGID,
&i.Talkgroup.Name,
&i.Talkgroup.AlphaTag,
&i.Talkgroup.TgGroup,
&i.Talkgroup.TGGroup,
&i.Talkgroup.Frequency,
&i.Talkgroup.Metadata,
&i.Talkgroup.Tags,

View file

@ -10,9 +10,62 @@ import (
"dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/alerting/rules"
"github.com/jackc/pgx/v5/pgtype"
)
const addLearnedTalkgroup = `-- name: AddLearnedTalkgroup :one
INSERT INTO talkgroups_learned(
system_id,
tgid,
name,
alpha_tag,
tg_group
) VALUES (
$1,
$2,
$3,
$4,
$5
) RETURNING id
`
type AddLearnedTalkgroupParams struct {
SystemID int `json:"system_id"`
TGID int `json:"tgid"`
Name *string `json:"name"`
AlphaTag *string `json:"alpha_tag"`
TGGroup *string `json:"tg_group"`
}
func (q *Queries) AddLearnedTalkgroup(ctx context.Context, arg AddLearnedTalkgroupParams) (int, error) {
row := q.db.QueryRow(ctx, addLearnedTalkgroup,
arg.SystemID,
arg.TGID,
arg.Name,
arg.AlphaTag,
arg.TGGroup,
)
var id int
err := row.Scan(&id)
return id, err
}
const addTalkgroupWithLearnedFlag = `-- name: AddTalkgroupWithLearnedFlag :exec
INSERT INTO talkgroups (
system_id,
tgid,
learned
) VALUES(
$1,
$2,
't'
)
`
func (q *Queries) AddTalkgroupWithLearnedFlag(ctx context.Context, systemID int32, tGID int32) error {
_, err := q.db.Exec(ctx, addTalkgroupWithLearnedFlag, systemID, tGID)
return err
}
const getSystemName = `-- name: GetSystemName :one
SELECT name FROM systems WHERE id = $1
`
@ -25,7 +78,7 @@ func (q *Queries) GetSystemName(ctx context.Context, systemID int) (string, erro
}
const getTalkgroup = `-- name: GetTalkgroup :one
SELECT talkgroups.id, talkgroups.system_id, talkgroups.tgid, talkgroups.name, talkgroups.alpha_tag, talkgroups.tg_group, talkgroups.frequency, talkgroups.metadata, talkgroups.tags, talkgroups.alert, talkgroups.alert_config, talkgroups.weight FROM talkgroups
SELECT talkgroups.id, talkgroups.system_id, talkgroups.tgid, talkgroups.name, talkgroups.alpha_tag, talkgroups.tg_group, talkgroups.frequency, talkgroups.metadata, talkgroups.tags, talkgroups.alert, talkgroups.alert_config, talkgroups.weight, talkgroups.learned FROM talkgroups
WHERE (system_id, tgid) = ($1, $2)
`
@ -33,8 +86,8 @@ type GetTalkgroupRow struct {
Talkgroup Talkgroup `json:"talkgroup"`
}
func (q *Queries) GetTalkgroup(ctx context.Context, systemID int32, tgID int32) (GetTalkgroupRow, error) {
row := q.db.QueryRow(ctx, getTalkgroup, systemID, tgID)
func (q *Queries) GetTalkgroup(ctx context.Context, systemID int32, tGID int32) (GetTalkgroupRow, error) {
row := q.db.QueryRow(ctx, getTalkgroup, systemID, tGID)
var i GetTalkgroupRow
err := row.Scan(
&i.Talkgroup.ID,
@ -42,13 +95,14 @@ func (q *Queries) GetTalkgroup(ctx context.Context, systemID int32, tgID int32)
&i.Talkgroup.TGID,
&i.Talkgroup.Name,
&i.Talkgroup.AlphaTag,
&i.Talkgroup.TgGroup,
&i.Talkgroup.TGGroup,
&i.Talkgroup.Frequency,
&i.Talkgroup.Metadata,
&i.Talkgroup.Tags,
&i.Talkgroup.Alert,
&i.Talkgroup.AlertConfig,
&i.Talkgroup.Weight,
&i.Talkgroup.Learned,
)
return i, err
}
@ -90,8 +144,8 @@ SELECT tags FROM talkgroups
WHERE system_id = $1 AND tgid = $2
`
func (q *Queries) GetTalkgroupTags(ctx context.Context, systemID int32, tgID int32) ([]string, error) {
row := q.db.QueryRow(ctx, getTalkgroupTags, systemID, tgID)
func (q *Queries) GetTalkgroupTags(ctx context.Context, systemID int32, tGID int32) ([]string, error) {
row := q.db.QueryRow(ctx, getTalkgroupTags, systemID, tGID)
var tags []string
err := row.Scan(&tags)
return tags, err
@ -99,18 +153,16 @@ func (q *Queries) GetTalkgroupTags(ctx context.Context, systemID int32, tgID int
const getTalkgroupWithLearned = `-- name: GetTalkgroupWithLearned :one
SELECT
tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, sys.id, sys.name,
FALSE learned
tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, tg.learned, sys.id, sys.name
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
WHERE (tg.system_id, tg.tgid) = ($1, $2)
WHERE (tg.system_id, tg.tgid) = ($1, $2) AND tg.learned IS NOT TRUE
UNION
SELECT
NULL::UUID, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.alpha_tag, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END,
TRUE, NULL::JSONB, 1.0, sys.id, sys.name,
TRUE learned
tgl.id, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.tg_group, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.tg_group IS NULL THEN NULL ELSE ARRAY[tgl.tg_group] END,
NOT tgl.ignored, NULL::JSONB, 1.0, TRUE learned, sys.id, sys.name
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
WHERE tgl.system_id = $1 AND tgl.tgid = $2 AND ignored IS NOT TRUE
@ -119,7 +171,6 @@ WHERE tgl.system_id = $1 AND tgl.tgid = $2 AND ignored IS NOT TRUE
type GetTalkgroupWithLearnedRow struct {
Talkgroup Talkgroup `json:"talkgroup"`
System System `json:"system"`
Learned bool `json:"learned"`
}
func (q *Queries) GetTalkgroupWithLearned(ctx context.Context, systemID int32, tGID int32) (GetTalkgroupWithLearnedRow, error) {
@ -131,22 +182,22 @@ func (q *Queries) GetTalkgroupWithLearned(ctx context.Context, systemID int32, t
&i.Talkgroup.TGID,
&i.Talkgroup.Name,
&i.Talkgroup.AlphaTag,
&i.Talkgroup.TgGroup,
&i.Talkgroup.TGGroup,
&i.Talkgroup.Frequency,
&i.Talkgroup.Metadata,
&i.Talkgroup.Tags,
&i.Talkgroup.Alert,
&i.Talkgroup.AlertConfig,
&i.Talkgroup.Weight,
&i.Talkgroup.Learned,
&i.System.ID,
&i.System.Name,
&i.Learned,
)
return i, err
}
const getTalkgroupsWithAllTags = `-- name: GetTalkgroupsWithAllTags :many
SELECT talkgroups.id, talkgroups.system_id, talkgroups.tgid, talkgroups.name, talkgroups.alpha_tag, talkgroups.tg_group, talkgroups.frequency, talkgroups.metadata, talkgroups.tags, talkgroups.alert, talkgroups.alert_config, talkgroups.weight FROM talkgroups
SELECT talkgroups.id, talkgroups.system_id, talkgroups.tgid, talkgroups.name, talkgroups.alpha_tag, talkgroups.tg_group, talkgroups.frequency, talkgroups.metadata, talkgroups.tags, talkgroups.alert, talkgroups.alert_config, talkgroups.weight, talkgroups.learned FROM talkgroups
WHERE tags && ARRAY[$1]
`
@ -169,13 +220,14 @@ func (q *Queries) GetTalkgroupsWithAllTags(ctx context.Context, tags []string) (
&i.Talkgroup.TGID,
&i.Talkgroup.Name,
&i.Talkgroup.AlphaTag,
&i.Talkgroup.TgGroup,
&i.Talkgroup.TGGroup,
&i.Talkgroup.Frequency,
&i.Talkgroup.Metadata,
&i.Talkgroup.Tags,
&i.Talkgroup.Alert,
&i.Talkgroup.AlertConfig,
&i.Talkgroup.Weight,
&i.Talkgroup.Learned,
); err != nil {
return nil, err
}
@ -188,7 +240,7 @@ func (q *Queries) GetTalkgroupsWithAllTags(ctx context.Context, tags []string) (
}
const getTalkgroupsWithAnyTags = `-- name: GetTalkgroupsWithAnyTags :many
SELECT talkgroups.id, talkgroups.system_id, talkgroups.tgid, talkgroups.name, talkgroups.alpha_tag, talkgroups.tg_group, talkgroups.frequency, talkgroups.metadata, talkgroups.tags, talkgroups.alert, talkgroups.alert_config, talkgroups.weight FROM talkgroups
SELECT talkgroups.id, talkgroups.system_id, talkgroups.tgid, talkgroups.name, talkgroups.alpha_tag, talkgroups.tg_group, talkgroups.frequency, talkgroups.metadata, talkgroups.tags, talkgroups.alert, talkgroups.alert_config, talkgroups.weight, talkgroups.learned FROM talkgroups
WHERE tags @> ARRAY[$1]
`
@ -211,13 +263,14 @@ func (q *Queries) GetTalkgroupsWithAnyTags(ctx context.Context, tags []string) (
&i.Talkgroup.TGID,
&i.Talkgroup.Name,
&i.Talkgroup.AlphaTag,
&i.Talkgroup.TgGroup,
&i.Talkgroup.TGGroup,
&i.Talkgroup.Frequency,
&i.Talkgroup.Metadata,
&i.Talkgroup.Tags,
&i.Talkgroup.Alert,
&i.Talkgroup.AlertConfig,
&i.Talkgroup.Weight,
&i.Talkgroup.Learned,
); err != nil {
return nil, err
}
@ -231,17 +284,16 @@ func (q *Queries) GetTalkgroupsWithAnyTags(ctx context.Context, tags []string) (
const getTalkgroupsWithLearned = `-- name: GetTalkgroupsWithLearned :many
SELECT
tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, sys.id, sys.name,
FALSE learned
tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, tg.learned, sys.id, sys.name
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
WHERE tg.learned IS NOT TRUE
UNION
SELECT
NULL::UUID, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.alpha_tag, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END,
TRUE, NULL::JSONB, 1.0, sys.id, sys.name,
TRUE learned
tgl.id, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.tg_group, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.tg_group IS NULL THEN NULL ELSE ARRAY[tgl.tg_group] END,
NOT tgl.ignored, NULL::JSONB, 1.0, TRUE learned, sys.id, sys.name
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
WHERE ignored IS NOT TRUE
@ -250,7 +302,6 @@ WHERE ignored IS NOT TRUE
type GetTalkgroupsWithLearnedRow struct {
Talkgroup Talkgroup `json:"talkgroup"`
System System `json:"system"`
Learned bool `json:"learned"`
}
func (q *Queries) GetTalkgroupsWithLearned(ctx context.Context) ([]GetTalkgroupsWithLearnedRow, error) {
@ -268,16 +319,16 @@ func (q *Queries) GetTalkgroupsWithLearned(ctx context.Context) ([]GetTalkgroups
&i.Talkgroup.TGID,
&i.Talkgroup.Name,
&i.Talkgroup.AlphaTag,
&i.Talkgroup.TgGroup,
&i.Talkgroup.TGGroup,
&i.Talkgroup.Frequency,
&i.Talkgroup.Metadata,
&i.Talkgroup.Tags,
&i.Talkgroup.Alert,
&i.Talkgroup.AlertConfig,
&i.Talkgroup.Weight,
&i.Talkgroup.Learned,
&i.System.ID,
&i.System.Name,
&i.Learned,
); err != nil {
return nil, err
}
@ -291,18 +342,16 @@ func (q *Queries) GetTalkgroupsWithLearned(ctx context.Context) ([]GetTalkgroups
const getTalkgroupsWithLearnedBySystem = `-- name: GetTalkgroupsWithLearnedBySystem :many
SELECT
tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, sys.id, sys.name,
FALSE learned
tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, tg.learned, sys.id, sys.name
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
WHERE tg.system_id = $1
WHERE tg.system_id = $1 AND tg.learned IS NOT TRUE
UNION
SELECT
NULL::UUID, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.alpha_tag, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END,
TRUE, NULL::JSONB, 1.0, sys.id, sys.name,
TRUE learned
tgl.id, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.tg_group, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.tg_group IS NULL THEN NULL ELSE ARRAY[tgl.tg_group] END,
NOT tgl.ignored, NULL::JSONB, 1.0, TRUE learned, sys.id, sys.name
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
WHERE tgl.system_id = $1 AND ignored IS NOT TRUE
@ -311,7 +360,6 @@ WHERE tgl.system_id = $1 AND ignored IS NOT TRUE
type GetTalkgroupsWithLearnedBySystemRow struct {
Talkgroup Talkgroup `json:"talkgroup"`
System System `json:"system"`
Learned bool `json:"learned"`
}
func (q *Queries) GetTalkgroupsWithLearnedBySystem(ctx context.Context, system int32) ([]GetTalkgroupsWithLearnedBySystemRow, error) {
@ -329,16 +377,16 @@ func (q *Queries) GetTalkgroupsWithLearnedBySystem(ctx context.Context, system i
&i.Talkgroup.TGID,
&i.Talkgroup.Name,
&i.Talkgroup.AlphaTag,
&i.Talkgroup.TgGroup,
&i.Talkgroup.TGGroup,
&i.Talkgroup.Frequency,
&i.Talkgroup.Metadata,
&i.Talkgroup.Tags,
&i.Talkgroup.Alert,
&i.Talkgroup.AlertConfig,
&i.Talkgroup.Weight,
&i.Talkgroup.Learned,
&i.System.ID,
&i.System.Name,
&i.Learned,
); err != nil {
return nil, err
}
@ -355,8 +403,8 @@ UPDATE talkgroups SET tags = $1
WHERE system_id = $2 AND tgid = $3
`
func (q *Queries) SetTalkgroupTags(ctx context.Context, tags []string, systemID int32, tgID int32) error {
_, err := q.db.Exec(ctx, setTalkgroupTags, tags, systemID, tgID)
func (q *Queries) SetTalkgroupTags(ctx context.Context, tags []string, systemID int32, tGID int32) error {
_, err := q.db.Exec(ctx, setTalkgroupTags, tags, systemID, tGID)
return err
}
@ -373,20 +421,20 @@ SET
alert_config = COALESCE($8, alert_config),
weight = COALESCE($9, weight)
WHERE id = $10 OR (system_id = $11 AND tgid = $12)
RETURNING id, system_id, tgid, name, alpha_tag, tg_group, frequency, metadata, tags, alert, alert_config, weight
RETURNING id, system_id, tgid, name, alpha_tag, tg_group, frequency, metadata, tags, alert, alert_config, weight, learned
`
type UpdateTalkgroupParams struct {
Name *string `json:"name"`
AlphaTag *string `json:"alpha_tag"`
TgGroup *string `json:"tg_group"`
TGGroup *string `json:"tg_group"`
Frequency *int32 `json:"frequency"`
Metadata jsontypes.Metadata `json:"metadata"`
Tags []string `json:"tags"`
Alert *bool `json:"alert"`
AlertConfig rules.AlertRules `json:"alert_config"`
Weight *float32 `json:"weight"`
ID pgtype.UUID `json:"id"`
ID *int32 `json:"id"`
SystemID *int32 `json:"system_id"`
TGID *int32 `json:"tgid"`
}
@ -395,7 +443,7 @@ func (q *Queries) UpdateTalkgroup(ctx context.Context, arg UpdateTalkgroupParams
row := q.db.QueryRow(ctx, updateTalkgroup,
arg.Name,
arg.AlphaTag,
arg.TgGroup,
arg.TGGroup,
arg.Frequency,
arg.Metadata,
arg.Tags,
@ -413,13 +461,14 @@ func (q *Queries) UpdateTalkgroup(ctx context.Context, arg UpdateTalkgroupParams
&i.TGID,
&i.Name,
&i.AlphaTag,
&i.TgGroup,
&i.TGGroup,
&i.Frequency,
&i.Metadata,
&i.Tags,
&i.Alert,
&i.AlertConfig,
&i.Weight,
&i.Learned,
)
return i, err
}

View file

@ -3,23 +3,21 @@ package database
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/assert"
)
const getTalkgroupWithLearnedTest = `-- name: GetTalkgroupWithLearned :one
SELECT
tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, sys.id, sys.name,
FALSE learned
tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, tg.learned, sys.id, sys.name
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
WHERE (tg.system_id, tg.tgid) = ($1, $2)
WHERE (tg.system_id, tg.tgid) = ($1, $2) AND tg.learned IS NOT TRUE
UNION
SELECT
NULL::UUID, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.alpha_tag, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END,
TRUE, NULL::JSONB, 1.0, sys.id, sys.name,
TRUE learned
tgl.id, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.tg_group, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.tg_group IS NULL THEN NULL ELSE ARRAY[tgl.tg_group] END,
NOT tgl.ignored, NULL::JSONB, 1.0, TRUE learned, sys.id, sys.name
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
WHERE tgl.system_id = $1 AND tgl.tgid = $2 AND ignored IS NOT TRUE
@ -27,18 +25,16 @@ WHERE tgl.system_id = $1 AND tgl.tgid = $2 AND ignored IS NOT TRUE
const getTalkgroupsWithLearnedBySystemTest = `-- name: GetTalkgroupsWithLearnedBySystem :many
SELECT
tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, sys.id, sys.name,
FALSE learned
tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, tg.learned, sys.id, sys.name
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
WHERE tg.system_id = $1
WHERE tg.system_id = $1 AND tg.learned IS NOT TRUE
UNION
SELECT
NULL::UUID, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.alpha_tag, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END,
TRUE, NULL::JSONB, 1.0, sys.id, sys.name,
TRUE learned
tgl.id, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.tg_group, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.tg_group IS NULL THEN NULL ELSE ARRAY[tgl.tg_group] END,
NOT tgl.ignored, NULL::JSONB, 1.0, TRUE learned, sys.id, sys.name
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
WHERE tgl.system_id = $1 AND ignored IS NOT TRUE
@ -46,24 +42,23 @@ WHERE tgl.system_id = $1 AND ignored IS NOT TRUE
const getTalkgroupsWithLearnedTest = `-- name: GetTalkgroupsWithLearned :many
SELECT
tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, sys.id, sys.name,
FALSE learned
tg.id, tg.system_id, tg.tgid, tg.name, tg.alpha_tag, tg.tg_group, tg.frequency, tg.metadata, tg.tags, tg.alert, tg.alert_config, tg.weight, tg.learned, sys.id, sys.name
FROM talkgroups tg
JOIN systems sys ON tg.system_id = sys.id
WHERE tg.learned IS NOT TRUE
UNION
SELECT
NULL::UUID, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.alpha_tag, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.alpha_tag IS NULL THEN NULL ELSE ARRAY[tgl.alpha_tag] END,
TRUE, NULL::JSONB, 1.0, sys.id, sys.name,
TRUE learned
tgl.id, tgl.system_id::INT4, tgl.tgid::INT4, tgl.name,
tgl.alpha_tag, tgl.tg_group, NULL::INTEGER, NULL::JSONB,
CASE WHEN tgl.tg_group IS NULL THEN NULL ELSE ARRAY[tgl.tg_group] END,
NOT tgl.ignored, NULL::JSONB, 1.0, TRUE learned, sys.id, sys.name
FROM talkgroups_learned tgl
JOIN systems sys ON tgl.system_id = sys.id
WHERE ignored IS NOT TRUE
`
func TestQueryColumnsMatch(t *testing.T) {
require.Equal(t, getTalkgroupWithLearnedTest, getTalkgroupWithLearned)
require.Equal(t, getTalkgroupsWithLearnedBySystemTest, getTalkgroupsWithLearnedBySystem)
require.Equal(t, getTalkgroupsWithLearnedTest, getTalkgroupsWithLearned)
assert.Equal(t, getTalkgroupWithLearnedTest, getTalkgroupWithLearned)
assert.Equal(t, getTalkgroupsWithLearnedBySystemTest, getTalkgroupsWithLearnedBySystem)
assert.Equal(t, getTalkgroupsWithLearnedTest, getTalkgroupsWithLearned)
}

View file

@ -113,7 +113,7 @@ SELECT id, username, password, email, is_admin, prefs FROM users
WHERE id = $1 LIMIT 1
`
func (q *Queries) GetUserByID(ctx context.Context, id int32) (User, error) {
func (q *Queries) GetUserByID(ctx context.Context, id int) (User, error) {
row := q.db.QueryRow(ctx, getUserByID, id)
var i User
err := row.Scan(
@ -132,7 +132,7 @@ SELECT id, username, password, email, is_admin, prefs FROM users
WHERE id = $1 LIMIT 1
`
func (q *Queries) GetUserByUID(ctx context.Context, id int32) (User, error) {
func (q *Queries) GetUserByUID(ctx context.Context, id int) (User, error) {
row := q.db.QueryRow(ctx, getUserByUID, id)
var i User
err := row.Scan(

View file

@ -78,7 +78,7 @@ func (c *client) Talkgroup(ctx context.Context, tg *pb.Talkgroup) error {
resp := &pb.TalkgroupInfo{
Tg: tg,
Name: tgi.Talkgroup.Name,
Group: tgi.Talkgroup.TgGroup,
Group: tgi.Talkgroup.TGGroup,
Frequency: tgi.Talkgroup.Frequency,
Metadata: md,
Tags: tgi.Talkgroup.Tags,

View file

@ -27,10 +27,11 @@ const shutdownTimeout = 5 * time.Second
type Server struct {
auth *auth.Auth
conf *config.Config
db database.DB
db database.Store
r *chi.Mux
sources sources.Sources
sinks sinks.Sinks
relayer *sinks.RelayManager
nex *nexus.Nexus
logger *Logger
alerter alerting.Alerter
@ -73,6 +74,7 @@ func New(ctx context.Context, cfg *config.Config) (*Server, error) {
alerter: alerting.New(cfg.Alerting, tgCache, alerting.WithNotifier(notifier)),
notifier: notifier,
tgs: tgCache,
sinks: sinks.NewSinkManager(),
rest: api,
}
@ -85,6 +87,13 @@ func New(ctx context.Context, cfg *config.Config) (*Server, error) {
srv.sources.Register("rdio-http", sources.NewRdioHTTP(authenticator, srv))
relayer, err := sinks.NewRelayManager(srv.sinks, cfg.Relay)
if err != nil {
return nil, err
}
srv.relayer = relayer
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(RequestLogger())
@ -103,7 +112,7 @@ func New(ctx context.Context, cfg *config.Config) (*Server, error) {
}
func (s *Server) Go(ctx context.Context) error {
defer s.db.DB().Close()
defer database.Close(s.db)
s.installHupHandler()

View file

@ -8,16 +8,17 @@ import (
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/database"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/rs/zerolog/log"
)
type DatabaseSink struct {
db database.DB
db database.Store
}
func NewDatabaseSink(db database.DB) *DatabaseSink {
return &DatabaseSink{db: db}
func NewDatabaseSink(store database.Store) *DatabaseSink {
return &DatabaseSink{store}
}
func (s *DatabaseSink) Call(ctx context.Context, call *calls.Call) error {
@ -26,14 +27,37 @@ func (s *DatabaseSink) Call(ctx context.Context, call *calls.Call) error {
return nil
}
err := s.db.AddCall(ctx, s.toAddCallParams(call))
if err != nil {
return fmt.Errorf("add call: %w", err)
params := s.toAddCallParams(call)
err := s.db.InTx(ctx, func(tx database.Store) error {
err := tx.AddCall(ctx, params)
if err != nil {
return fmt.Errorf("add call: %w", err)
}
log.Debug().Str("id", call.ID.String()).Int("system", call.System).Int("tgid", call.Talkgroup).Msg("stored")
return nil
}, pgx.TxOptions{})
if err != nil && database.IsTGConstraintViolation(err) {
return s.db.InTx(ctx, func(tx database.Store) error {
_, err := call.LearnTG(ctx, tx)
if err != nil {
return fmt.Errorf("add call: learn tg: %w", err)
}
err = tx.AddCall(ctx, params)
if err != nil {
return fmt.Errorf("add call: retry: %w", err)
}
return nil
}, pgx.TxOptions{})
}
log.Debug().Str("id", call.ID.String()).Int("system", call.System).Int("tgid", call.Talkgroup).Msg("stored")
return nil
return err
}
func (s *DatabaseSink) SinkType() string {
@ -54,9 +78,9 @@ func (s *DatabaseSink) toAddCallParams(call *calls.Call) database.AddCallParams
Frequency: call.Frequency,
Frequencies: call.Frequencies,
Patches: call.Patches,
TgLabel: call.TalkgroupLabel,
TgAlphaTag: call.TGAlphaTag,
TgGroup: call.TalkgroupGroup,
TGLabel: call.TalkgroupLabel,
TGAlphaTag: call.TGAlphaTag,
TGGroup: call.TalkgroupGroup,
Source: call.Source,
}
}

118
pkg/sinks/relay.go Normal file
View file

@ -0,0 +1,118 @@
package sinks
import (
"bytes"
"context"
"fmt"
"mime/multipart"
"net/http"
"net/url"
"dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/internal/version"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/config"
)
type RelayManager struct {
xp *http.Transport
client *http.Client
relays []*Relay
}
type Relay struct {
config.Relay
mgr *RelayManager
Name string
url *url.URL
}
func NewRelayManager(s Sinks, cfgs []config.Relay) (*RelayManager, error) {
xp := http.DefaultTransport.(*http.Transport).Clone()
xp.MaxIdleConnsPerHost = 10
client := &http.Client{
Transport: xp,
}
rm := &RelayManager{
xp: xp,
client: client,
relays: make([]*Relay, 0, len(cfgs)),
}
for i, cfg := range cfgs {
rs, err := rm.newRelay(i, cfg)
if err != nil {
return nil, err
}
rm.relays = append(rm.relays, rs)
s.Register(rs.Name, rs, cfg.Required)
}
return rm, nil
}
func (rs *RelayManager) newRelay(idx int, cfg config.Relay) (*Relay, error) {
u, err := url.Parse(cfg.URL)
if err != nil {
return nil, err
}
if u.Path != "" && u.Path != "/" {
return nil, fmt.Errorf("relay path in %s must be instance root", cfg.URL)
}
u = u.JoinPath("/api/call-upload")
return &Relay{
Name: fmt.Sprintf("relay%d:%s", idx, u.Host),
Relay: cfg,
url: u,
mgr: rs,
}, nil
}
func (s *Relay) Call(ctx context.Context, call *calls.Call) error {
var buf bytes.Buffer
body := multipart.NewWriter(&buf)
err := forms.Marshal(call, body)
if err != nil {
return fmt.Errorf("relay form parse: %w", err)
}
err = body.WriteField("key", s.APIKey)
if err != nil {
return fmt.Errorf("relay set API key: %w", err)
}
body.Close()
r, err := http.NewRequestWithContext(ctx, http.MethodPost, s.url.String(), &buf)
if err != nil {
return fmt.Errorf("relay newrequest: %w", err)
}
r.Header.Set("Content-Type", body.FormDataContentType())
r.Header.Set("User-Agent", version.HttpString("call-relay"))
resp, err := s.mgr.client.Do(r)
if err != nil {
return fmt.Errorf("relay %s: %w", s.Name, err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("relay %s: received HTTP %d", s.Name, resp.StatusCode)
}
return nil
}
func (s *Relay) SinkType() string {
return "relay"
}

Some files were not shown because too many files have changed in this diff Show more