├── .env.test ├── .github ├── FUNDING.yml └── workflows │ └── docker-build.yaml ├── .gitignore ├── README.md ├── account ├── .dockerignore ├── .eslintcache ├── .gitignore ├── Dockerfile ├── notes.md ├── package.json ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── index.html │ ├── robots.txt │ └── site.webmanifest ├── server │ ├── config │ │ ├── express.js │ │ ├── passport-strategies │ │ │ ├── local.js │ │ │ └── twitter.js │ │ ├── passport.js │ │ └── secrets.js │ ├── controllers │ │ ├── systems.js │ │ └── users.js │ ├── index.js │ ├── models │ │ ├── system.js │ │ └── user.js │ └── public │ │ └── style │ │ └── style.css ├── src │ ├── App │ │ ├── App.js │ │ ├── Navigation.js │ │ └── Restricted.js │ ├── User │ │ ├── ConfirmEmail.js │ │ ├── Login.js │ │ ├── Profile.js │ │ ├── Register.js │ │ ├── ResetPassword.js │ │ ├── SendResetPassword.js │ │ ├── SentConfirmEmail.js │ │ ├── Terms.js │ │ ├── UserForm.js │ │ ├── WaitConfirmEmail.js │ │ ├── user-actions.js │ │ ├── user-constants.js │ │ └── user-reducer.js │ ├── features │ │ ├── api │ │ │ └── apiSlice.js │ │ └── user │ │ │ └── userSlice.js │ ├── index.js │ ├── query-string.js │ └── redux-router │ │ ├── configureStore.js │ │ └── reducers.js └── yarn.lock ├── admin ├── .dockerignore ├── .eslintcache ├── .gitignore ├── Dockerfile ├── notes.md ├── package-lock.json ├── package.json ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── index.html │ ├── robots.txt │ └── site.webmanifest ├── server │ ├── config │ │ ├── express.js │ │ ├── passport-strategies │ │ │ └── local.js │ │ ├── passport.js │ │ └── secrets.js │ ├── controllers │ │ ├── groups.js │ │ ├── systems.js │ │ └── talkgroups.js │ ├── index.js │ ├── models │ │ ├── group.js │ │ ├── system.js │ │ ├── system_stat.js │ │ ├── talkgroup.js │ │ └── user.js │ └── public │ │ └── style │ │ └── style.css ├── src │ ├── ActiveUsers │ │ └── ActiveUsers.js │ ├── AllSystems │ │ ├── AllSystems.js │ │ └── SystemRow.js │ ├── App │ │ ├── App.js │ │ ├── Navigation.js │ │ └── Restricted.js │ ├── Components │ │ ├── About.js │ │ └── Message.js │ ├── Group │ │ ├── GroupModal.js │ │ └── ListGroups.js │ ├── System │ │ ├── CallChart.js │ │ ├── CreateSystem.js │ │ ├── ErrorChart.js │ │ ├── ListSystems.js │ │ ├── ResponsiveCallChart.js │ │ ├── ResponsiveErrorChart.js │ │ ├── System.js │ │ ├── SystemCard.js │ │ ├── SystemForm.js │ │ └── UpdateSystem.js │ ├── Talkgroups │ │ └── ListTalkgroups.js │ ├── features │ │ ├── api │ │ │ └── apiSlice.js │ │ └── user │ │ │ └── userSlice.js │ ├── index.js │ └── redux-router │ │ └── configureStore.js └── yarn.lock ├── backend ├── .gitignore ├── Dockerfile ├── agents │ ├── honeycomb-tracing.js │ └── otel-tracing.js ├── config │ ├── config.json │ └── express.js ├── controllers │ ├── calls.js │ ├── events.js │ ├── groups.js │ ├── sources.js │ ├── stats.js │ ├── systems.js │ ├── talkgroups.js │ └── uploads.js ├── db.js ├── index.js ├── models │ ├── call.js │ ├── callSchema.js │ ├── event.js │ ├── frozenCall.js │ ├── frozenCallSchema.js │ ├── group.js │ ├── permission.js │ ├── podcast.js │ ├── star.js │ ├── system.js │ ├── systemSchema.js │ ├── system_stat.js │ ├── talkgroup.js │ ├── talkgroupSchema.js │ └── user.js ├── package-lock.json ├── package.json ├── public │ ├── css │ │ └── player.css │ ├── filler │ │ ├── 10silence.m4a │ │ ├── 30silence.m4a │ │ └── 60silence.m4a │ ├── img │ │ └── ajax-loader.gif │ ├── js │ │ └── admin.js │ ├── semantic-ui │ │ ├── components │ │ │ ├── accordion.css │ │ │ ├── accordion.js │ │ │ ├── accordion.min.css │ │ │ ├── accordion.min.js │ │ │ ├── ad.css │ │ │ ├── ad.min.css │ │ │ ├── api.js │ │ │ ├── api.min.js │ │ │ ├── breadcrumb.css │ │ │ ├── breadcrumb.min.css │ │ │ ├── button.css │ │ │ ├── button.min.css │ │ │ ├── card.css │ │ │ ├── card.min.css │ │ │ ├── checkbox.css │ │ │ ├── checkbox.js │ │ │ ├── checkbox.min.css │ │ │ ├── checkbox.min.js │ │ │ ├── colorize.js │ │ │ ├── colorize.min.js │ │ │ ├── comment.css │ │ │ ├── comment.min.css │ │ │ ├── container.css │ │ │ ├── container.min.css │ │ │ ├── dimmer.css │ │ │ ├── dimmer.js │ │ │ ├── dimmer.min.css │ │ │ ├── dimmer.min.js │ │ │ ├── divider.css │ │ │ ├── divider.min.css │ │ │ ├── dropdown.css │ │ │ ├── dropdown.js │ │ │ ├── dropdown.min.css │ │ │ ├── dropdown.min.js │ │ │ ├── embed.css │ │ │ ├── embed.js │ │ │ ├── embed.min.css │ │ │ ├── embed.min.js │ │ │ ├── feed.css │ │ │ ├── feed.min.css │ │ │ ├── flag.css │ │ │ ├── flag.min.css │ │ │ ├── form.css │ │ │ ├── form.js │ │ │ ├── form.min.css │ │ │ ├── form.min.js │ │ │ ├── grid.css │ │ │ ├── grid.min.css │ │ │ ├── header.css │ │ │ ├── header.min.css │ │ │ ├── icon.css │ │ │ ├── icon.min.css │ │ │ ├── image.css │ │ │ ├── image.min.css │ │ │ ├── input.css │ │ │ ├── input.min.css │ │ │ ├── item.css │ │ │ ├── item.min.css │ │ │ ├── label.css │ │ │ ├── label.min.css │ │ │ ├── list.css │ │ │ ├── list.min.css │ │ │ ├── loader.css │ │ │ ├── loader.min.css │ │ │ ├── menu.css │ │ │ ├── menu.min.css │ │ │ ├── message.css │ │ │ ├── message.min.css │ │ │ ├── modal.css │ │ │ ├── modal.js │ │ │ ├── modal.min.css │ │ │ ├── modal.min.js │ │ │ ├── nag.css │ │ │ ├── nag.js │ │ │ ├── nag.min.css │ │ │ ├── nag.min.js │ │ │ ├── popup.css │ │ │ ├── popup.js │ │ │ ├── popup.min.css │ │ │ ├── popup.min.js │ │ │ ├── progress.css │ │ │ ├── progress.js │ │ │ ├── progress.min.css │ │ │ ├── progress.min.js │ │ │ ├── rail.css │ │ │ ├── rail.min.css │ │ │ ├── rating.css │ │ │ ├── rating.js │ │ │ ├── rating.min.css │ │ │ ├── rating.min.js │ │ │ ├── reset.css │ │ │ ├── reset.min.css │ │ │ ├── reveal.css │ │ │ ├── reveal.min.css │ │ │ ├── search.css │ │ │ ├── search.js │ │ │ ├── search.min.css │ │ │ ├── search.min.js │ │ │ ├── segment.css │ │ │ ├── segment.min.css │ │ │ ├── shape.css │ │ │ ├── shape.js │ │ │ ├── shape.min.css │ │ │ ├── shape.min.js │ │ │ ├── sidebar.css │ │ │ ├── sidebar.js │ │ │ ├── sidebar.min.css │ │ │ ├── sidebar.min.js │ │ │ ├── site.css │ │ │ ├── site.js │ │ │ ├── site.min.css │ │ │ ├── site.min.js │ │ │ ├── state.js │ │ │ ├── state.min.js │ │ │ ├── statistic.css │ │ │ ├── statistic.min.css │ │ │ ├── step.css │ │ │ ├── step.min.css │ │ │ ├── sticky.css │ │ │ ├── sticky.js │ │ │ ├── sticky.min.css │ │ │ ├── sticky.min.js │ │ │ ├── tab.css │ │ │ ├── tab.js │ │ │ ├── tab.min.css │ │ │ ├── tab.min.js │ │ │ ├── table.css │ │ │ ├── table.min.css │ │ │ ├── transition.css │ │ │ ├── transition.js │ │ │ ├── transition.min.css │ │ │ ├── transition.min.js │ │ │ ├── video.css │ │ │ ├── video.js │ │ │ ├── video.min.css │ │ │ ├── video.min.js │ │ │ ├── visibility.js │ │ │ ├── visibility.min.js │ │ │ ├── visit.js │ │ │ └── visit.min.js │ │ ├── semantic.css │ │ ├── semantic.js │ │ ├── semantic.min.css │ │ └── semantic.min.js │ └── skin │ │ ├── blue.monday │ │ ├── css │ │ │ ├── jplayer.blue.monday.css │ │ │ └── jplayer.blue.monday.min.css │ │ ├── image │ │ │ ├── jplayer.blue.monday.jpg │ │ │ ├── jplayer.blue.monday.seeking.gif │ │ │ └── jplayer.blue.monday.video.play.png │ │ └── mustache │ │ │ ├── jplayer.blue.monday.audio.playlist.html │ │ │ ├── jplayer.blue.monday.audio.single.html │ │ │ ├── jplayer.blue.monday.audio.stream.html │ │ │ ├── jplayer.blue.monday.video.playlist.html │ │ │ └── jplayer.blue.monday.video.single.html │ │ └── pink.flag │ │ ├── css │ │ ├── jplayer.pink.flag.css │ │ └── jplayer.pink.flag.min.css │ │ ├── image │ │ ├── jplayer.pink.flag.jpg │ │ ├── jplayer.pink.flag.seeking.gif │ │ └── jplayer.pink.flag.video.play.png │ │ └── mustache │ │ ├── jplayer.pink.flag.audio.playlist.html │ │ ├── jplayer.pink.flag.audio.single.html │ │ ├── jplayer.pink.flag.audio.stream.html │ │ ├── jplayer.pink.flag.video.playlist.html │ │ └── jplayer.pink.flag.video.single.html ├── sys_stats.js └── test │ └── spec.js ├── certbot-compose.yml ├── data ├── log │ ├── nginx │ │ └── .gitignore │ └── syslog │ │ └── .gitignore ├── media │ └── .gitignore └── upload │ └── .gitignore ├── docker-compose.yml ├── docker-prod.sh ├── docker-test.sh ├── frontend ├── .dockerignore ├── .eslintcache ├── .gitignore ├── .not-env ├── Dockerfile ├── env-example ├── notes.md ├── package-lock.json ├── package.json ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── app-terms.html │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── index.html │ ├── podcast │ │ ├── channel_icon.png │ │ └── cover.png │ ├── privacy.html │ ├── radio-400x400.jpg │ ├── robots.txt │ ├── silence.m4a │ └── site.webmanifest ├── server │ ├── card.ejs │ ├── config │ │ ├── express.js │ │ └── secrets.js │ ├── db.js │ ├── env.ejs │ └── index.js ├── src │ ├── About │ │ ├── AboutComponent.js │ │ └── Terms.js │ ├── Call │ │ ├── Activity.js │ │ ├── ActivityChart.js │ │ ├── BetterActivityChart.js │ │ ├── CallInfo.js │ │ ├── CallPlayer.css │ │ ├── CallPlayer.js │ │ ├── Calls.js │ │ └── components │ │ │ ├── CalendarModal.css │ │ │ ├── CalendarModal.js │ │ │ ├── CallInfo.js │ │ │ ├── CallInfoPane.js │ │ │ ├── CallItem.js │ │ │ ├── CallLinks.js │ │ │ ├── FilterModal.css │ │ │ ├── FilterModal.js │ │ │ ├── GroupModal.js │ │ │ ├── ListCalls.js │ │ │ ├── MediaPlayer.css │ │ │ ├── MediaPlayer.js │ │ │ ├── PlaylistBuilder.js │ │ │ └── PlaylistItem.js │ ├── Common │ │ ├── NavBar.js │ │ └── SupportModal.js │ ├── Event │ │ ├── EventCallInfo.js │ │ ├── EventCallItem.js │ │ ├── EventPlayer.js │ │ ├── ListEventCalls.js │ │ ├── ListEvents.js │ │ └── ViewEvent.js │ ├── Main │ │ ├── Main.css │ │ └── Main.js │ ├── System │ │ ├── ContactModal.js │ │ ├── InternationList.js │ │ ├── ListSystems.js │ │ ├── StateLinkList.js │ │ ├── SystemCard.js │ │ ├── SystemsByState.js │ │ └── TrendingList.js │ ├── app.js │ ├── features │ │ ├── api │ │ │ └── apiSlice.js │ │ ├── callPlayer │ │ │ └── callPlayerSlice.js │ │ ├── calls │ │ │ └── callsSlice.js │ │ ├── group │ │ │ └── groupSlice.js │ │ └── systems │ │ │ └── systemsSlice.js │ ├── index.js │ ├── query-string.js │ ├── redux-router │ │ └── configureStore.js │ ├── resources │ │ ├── DidactGothic-Regular.ttf │ │ └── logo.png │ └── tracking.js └── yarn.lock ├── local-dev-with-minio.MD ├── mongo ├── Dockerfile ├── clean.js ├── crontab ├── errors.js ├── init_test_db.js ├── permissions.js ├── remove_old_systems.js ├── remove_tg.js ├── totals.js └── upgrade_db_admin.js ├── nginx-proxy ├── Dockerfile ├── cert │ └── vhosts │ │ └── site.template ├── nginx.conf ├── prod │ └── vhosts │ │ └── site.template ├── proxy.conf └── test │ └── vhosts │ └── site.template ├── node_modules ├── .package-lock.json └── .yarn-integrity ├── package-lock.json ├── prod.env.example ├── rebuild.sh ├── test-compose.yml ├── test.env.example └── yarn.lock /.env.test: -------------------------------------------------------------------------------- 1 | MEDIA=/Users/luke/Testing/media 2 | DATA=/Users/luke/Testing/data 3 | MAILJET_KEY="4d2a0f6deb20a7890afee2daf595c22d" 4 | MAILJET_SECRET="fb74f682d68bf6b05ab626c0ac93a6da" 5 | GOOGLE_ANALYTICS="UA-45563211-1" 6 | STAGE=test 7 | TAG=1.7 8 | DOMAIN_NAME="openmhz.test" 9 | PROTOCOL="http://" 10 | 11 | 12 | REACT_APP_BACKEND_SERVER="${PROTOCOL}api.${DOMAIN_NAME}" 13 | REACT_APP_FRONTEND_SERVER="${PROTOCOL}${DOMAIN_NAME}" 14 | REACT_APP_ACCOUNT_SERVER="${PROTOCOL}account.${DOMAIN_NAME}" 15 | REACT_APP_ADMIN_SERVER="${PROTOCOL}admin.${DOMAIN_NAME}" 16 | REACT_APP_COOKIE_DOMAIN=".${DOMAIN_NAME}" 17 | REACT_APP_ADMIN_EMAIL=luke@robotastic.com 18 | REACT_APP_SITE_NAME=OpenMHz 19 | REACT_APP_ARCHIVE_DAYS=30 20 | STRIPE_PUBLISHABLE_KEY="" 21 | STRIPE_SECRET_KEY="sk_test_S5kfQwdyRDgKtu9FQYpPh3B3" 22 | BACKEND_SERVER="${PROTOCOL}api.${DOMAIN_NAME}" 23 | FRONTEND_SERVER="${PROTOCOL}${DOMAIN_NAME}" 24 | ADMIN_SERVER="${PROTOCOL}admin.${DOMAIN_NAME}" 25 | ACCOUNT_SERVER="${PROTOCOL}account.${DOMAIN_NAME}" 26 | COOKIE_DOMAIN=".${DOMAIN_NAME}" 27 | SITE_NAME="OpenMHz" 28 | ADMIN_EMAIL="luke@robotastic.com" 29 | S3_PROFILE='wasabi-account' 30 | S3_ENPOINT='s3.us-west-1.wasabisys.com' 31 | FREE_PLAN=0 32 | PRO_PLAN=10 33 | FREE_PLAN_PRICE=0 34 | PRO_PLAN_PRICE=15 35 | FREE_PLAN_ARCHIVE=7 36 | PRO_PLAN_ARCHIVE=30 -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: robotastic 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yaml: -------------------------------------------------------------------------------- 1 | name: Container Processing 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | 7 | env: 8 | # Setting an environment variable with the value of a configuration variable 9 | REACT_APP_GOOGLE_ANALYTICS: ${{ vars.REACT_APP_GOOGLE_ANALYTICS}} 10 | REACT_APP_BACKEND_SERVER: ${{ vars.REACT_APP_BACKEND_SERVER}} 11 | REACT_APP_FRONTEND_SERVER: ${{ vars.REACT_APP_FRONTEND_SERVER}} 12 | REACT_APP_ADMIN_SERVER: ${{ vars.REACT_APP_ADMIN_SERVER}} 13 | REACT_APP_ACCOUNT_SERVER: ${{ vars.REACT_APP_ACCOUNT_SERVER}} 14 | REACT_APP_COOKIE_DOMAIN: ${{ vars.REACT_APP_COOKIE_DOMAIN}} 15 | REACT_APP_ADMIN_EMAIL: ${{ vars.REACT_APP_ADMIN_EMAIL}} 16 | REACT_APP_SITE_NAME: ${{ vars.REACT_APP_SITE_NAME}} 17 | STAGE: ${{ vars.STAGE}} 18 | 19 | jobs: 20 | push_to_registry: 21 | strategy: 22 | matrix: 23 | service: [account, admin, backend, frontend, mongo, nginx-proxy] 24 | name: Push Docker image to DockerHub 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | 30 | - name: Set up Docker buildx 31 | id: buildx 32 | uses: docker/setup-buildx-action@v3 33 | with: 34 | version: latest 35 | 36 | - name: Log in to DockerHub 37 | uses: docker/login-action@v3 38 | with: 39 | username: ${{ vars.DOCKER_USERNAME }} 40 | password: ${{ secrets.DOCKER_TOKEN }} 41 | 42 | - name: Build and push Docker image 43 | uses: docker/build-push-action@v5 44 | with: 45 | context: ${{ matrix.service }} 46 | platforms: linux/arm64,linux/amd64 47 | push: true 48 | tags: ${{ vars.DOCKER_NAMESPACE }}/trunk-server-${{ matrix.service }}:latest 49 | build-args: STAGE=${{vars.STAGE}} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | prod.env 3 | test.env 4 | account/.eslintcache 5 | account/.eslintcache 6 | admin/.eslintcache 7 | frontend/.eslintcache 8 | admin/.eslintcache 9 | data/* 10 | -------------------------------------------------------------------------------- /account/.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /build -------------------------------------------------------------------------------- /account/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /account/Dockerfile: -------------------------------------------------------------------------------- 1 | # build environment 2 | FROM node:19-alpine3.16 as build 3 | WORKDIR /app 4 | ENV PATH /app/node_modules/.bin:$PATH 5 | ARG REACT_APP_BACKEND_SERVER 6 | ARG REACT_APP_ADMIN_SERVER 7 | ARG REACT_APP_ACCOUNT_SERVER 8 | ARG REACT_APP_FRONTEND_SERVER 9 | ARG REACT_APP_SITE_NAME 10 | ARG REACT_APP_ADMIN_EMAIL 11 | ARG NODE_ENV 12 | COPY package.json ./ 13 | RUN npm install --include=dev 14 | COPY . ./ 15 | RUN npm run build 16 | RUN env 17 | 18 | # production environment 19 | FROM node:19-alpine3.16 20 | RUN mkdir -p /app/public 21 | COPY ./package.json /tmp 22 | RUN cd /tmp && npm install 23 | RUN cp -a /tmp/node_modules /app 24 | RUN env 25 | RUN npm install -g nodemon 26 | WORKDIR /app 27 | COPY server /app 28 | COPY --from=build /app/build /app/public 29 | CMD ["node", "index.js"] -------------------------------------------------------------------------------- /account/notes.md: -------------------------------------------------------------------------------- 1 | 2 | ## Base 3 | start using `npx create-react-app account --template redux` 4 | 5 | ## Packages used: 6 | - [connected-react-router](https://github.com/supasate/connected-react-router): Synchronize router state with redux store through 7 | - [Redux Thunk](https://github.com/reduxjs/redux-thunk): lets you do AJAX calls 8 | - [axios](https://github.com/axios/axios) - library for doing HTTP requests 9 | - [react-router-dom](https://reactrouter.com/web/guides/quick-start) - Adds routes to React 10 | - [Semantic UI](https://react.semantic-ui.com/usage) - The UI frontend 11 | -------------------------------------------------------------------------------- /account/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "account", 3 | "version": "0.1.0", 4 | "private": true, 5 | "devDependencies": { 6 | "@reduxjs/toolkit": "^1.1.0", 7 | "@testing-library/jest-dom": "^5.16.5", 8 | "@testing-library/react": "^14.0.0", 9 | "@testing-library/user-event": "^14.4.3", 10 | "axios": "1.3.4", 11 | "react": "^18.2.0", 12 | "react-dom": "^18.2.0", 13 | "react-redux": "^8.0.5", 14 | "react-router-dom": "6.9.0", 15 | "react-scripts": "5.0.1", 16 | "redux-thunk": "2.4.2", 17 | "semantic-ui-css": "2.5.0", 18 | "semantic-ui-react": "2.1.4" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "test": "react-scripts test", 24 | "eject": "react-scripts eject" 25 | }, 26 | "eslintConfig": { 27 | "extends": "react-app" 28 | }, 29 | "browserslist": { 30 | "production": [ 31 | ">0.2%", 32 | "not dead", 33 | "not op_mini all" 34 | ], 35 | "development": [ 36 | "last 1 chrome version", 37 | "last 1 firefox version", 38 | "last 1 safari version" 39 | ] 40 | }, 41 | "dependencies": { 42 | "bcrypt": "5.1.0", 43 | "connect-mongo": "5.0.0", 44 | "express": "4.18.2", 45 | "express-session": "1.17.3", 46 | "mongoose": "7.0.3", 47 | "node-mailjet": "6.0.2", 48 | "passport": "0.6.0", 49 | "passport-local": "1.0.0", 50 | "passport-twitter": "1.0.4" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /account/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/account/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /account/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/account/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /account/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/account/public/apple-touch-icon.png -------------------------------------------------------------------------------- /account/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/account/public/favicon-16x16.png -------------------------------------------------------------------------------- /account/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/account/public/favicon-32x32.png -------------------------------------------------------------------------------- /account/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/account/public/favicon.ico -------------------------------------------------------------------------------- /account/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | %REACT_APP_SITE_NAME% 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /account/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /account/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /account/server/config/passport-strategies/local.js: -------------------------------------------------------------------------------- 1 | /* 2 | Configuring local strategy to authenticate strategies 3 | Code modified from : https://github.com/madhums/node-express-mongoose-demo/blob/master/config/passport/local.js 4 | */ 5 | 6 | const LocalStrategy = require("passport-local").Strategy; 7 | const User = require("../../models/user"); 8 | /* 9 | By default, LocalStrategy expects to find credentials in parameters named username and password. 10 | If your site prefers to name these fields differently, options are available to change the defaults. 11 | */ 12 | const local = new LocalStrategy({ 13 | usernameField: "email" 14 | }, async (email, password, done) => { 15 | 16 | // https://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex/6969486#6969486 17 | const escapeRegExp = (string) => { 18 | return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string 19 | } 20 | const user = await User.findOne({ 21 | $or: [{ 22 | email: { '$regex': escapeRegExp(email), $options: 'i' } 23 | }, { 24 | local: { 25 | email: { '$regex': escapeRegExp(email), $options: 'i' } 26 | } 27 | }] 28 | }); 29 | if (!user) { 30 | console.error("Auth Error - user not found: " + email); 31 | return done(null, false, { message: `Invalid email or password`, reason: "invalid" }) 32 | } 33 | if (!user.confirmEmail) { 34 | console.error("Auth Error - user has not confirmed email: " + email); 35 | return done(null, false, { message: `User's email is not confirmed`, reason: "unconfirmed email"}) 36 | } 37 | /* 38 | if (user.terms != 1.1) { 39 | console.error("Auth Error - user has not accepted TOS: " + email); 40 | return done(null, false, { message: `User has not accepted the Terms of Service`, reason: "unaccepted TOS"}) 41 | }*/ 42 | user.comparePassword(password, (err, isMatch) => { 43 | if (isMatch) { 44 | return done(null, user) 45 | } else { 46 | console.error("Auth Error - password mismatch: " + email); 47 | return done(null, false, { message: "Invalid email or password", reason: "invalid"}) 48 | } 49 | }) 50 | }) 51 | module.exports = local; -------------------------------------------------------------------------------- /account/server/config/passport-strategies/twitter.js: -------------------------------------------------------------------------------- 1 | var Strategy = require('passport-twitter').Strategy; 2 | const User = require("../../models/user"); 3 | 4 | const twitter = new Strategy({ 5 | consumerKey: process.env['TWITTER_CONSUMER_KEY'], 6 | consumerSecret: process.env['TWITTER_CONSUMER_SECRET'], 7 | callbackURL: '/oauth/callback' 8 | }, 9 | function(token, tokenSecret, profile, cb) { 10 | // In this example, the user's Twitter profile is supplied as the user 11 | // record. In a production-quality application, the Twitter profile should 12 | // be associated with a user record in the application's database, which 13 | // allows for account linking and authentication with other identity 14 | // providers. 15 | return cb(null, profile); 16 | }) 17 | 18 | module.exports = local; -------------------------------------------------------------------------------- /account/server/config/passport.js: -------------------------------------------------------------------------------- 1 | /* Initializing PassportJS */ 2 | const User = require("../models/user"); 3 | const local = require("./passport-strategies/local"); 4 | 5 | module.exports = function (app, passport) { 6 | // Configure Passport authenticated session persistence. 7 | // 8 | // In order to restore authentication state across HTTP requests, Passport needs 9 | // to serialize users into and deserialize users out of the session. The 10 | // typical implementation of this is as simple as supplying the user ID when 11 | // serializing, and querying the user record by ID from the database when 12 | // deserializing. 13 | passport.serializeUser((user, done) => { 14 | done(null, user.id) 15 | }) 16 | 17 | passport.deserializeUser(async (id, done) => { 18 | const user = await User.findById(id).exec(); 19 | if (user) { 20 | done(null,user); 21 | } else { 22 | done("User not found", null); 23 | } 24 | }) 25 | 26 | // use the following strategies 27 | passport.use(local) 28 | } 29 | 30 | -------------------------------------------------------------------------------- /account/server/config/secrets.js: -------------------------------------------------------------------------------- 1 | /** Important **/ 2 | /** You should not be committing this file to GitHub **/ 3 | /** Repeat: DO! NOT! COMMIT! THIS! FILE! TO! YOUR! REPO! **/ 4 | const mongo_host = typeof process.env['MONGO_HOST'] !== 'undefined' ? process.env['MONGO_HOST'] : 'mongo'; 5 | const mongo_port = typeof process.env['MONGO_PORT'] !== 'undefined' ? process.env['MONGO_PORT'] : 27017; 6 | const mongo_user = process.env['MONGO_USER']; 7 | const mongo_password = process.env['MONGO_PASSWORD']; 8 | 9 | let mongoUrl; 10 | 11 | if ((typeof mongo_user !== 'undefined') && (typeof mongo_password !== 'undefined')) { 12 | console.log("Using authentication for MongoDB - user: " + mongo_user); 13 | mongoUrl = 'mongodb://' + mongo_user + ':' + mongo_password + '@' + mongo_host + ':' + mongo_port + '/scanner'; 14 | } else { 15 | mongoUrl = 'mongodb://' + mongo_host + ':' + mongo_port + '/scanner'; 16 | } 17 | 18 | const secrets = { 19 | db: mongoUrl, 20 | sessionSecret: "letthisbeyoursecret" 21 | } 22 | 23 | module.exports = secrets 24 | -------------------------------------------------------------------------------- /account/server/controllers/systems.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const System = require("../models/system"); 3 | 4 | 5 | 6 | // ------------------------------------------- 7 | 8 | exports.getUserSystems = async function (_id) { 9 | console.log("Listing Systems for: " + _id); 10 | const userId = new mongoose.Types.ObjectId(req.user._id); 11 | const systems = await System.find({ userId: userId }); 12 | if (systems == null) { 13 | return ({ success: false, message: err }); 14 | } 15 | 16 | var returnSys = systems.map(obj => { 17 | var rObj = (({ 18 | name, 19 | shortName, 20 | description, 21 | status, 22 | systemType, 23 | city, 24 | state, 25 | county, 26 | country, 27 | userId, 28 | key, 29 | planType, 30 | showScreenName 31 | }) => ({ 32 | name, 33 | shortName, 34 | description, 35 | status, 36 | systemType, 37 | city, 38 | state, 39 | county, 40 | country, 41 | userId, 42 | key, 43 | planType, 44 | showScreenName 45 | }))(obj); 46 | rObj.id = obj._id; 47 | return rObj; 48 | }); 49 | 50 | 51 | return ({ success: true, systems: returnSys }); 52 | } 53 | 54 | 55 | exports.listSystems = function (req, res, next) { 56 | return listSystems(req.user._id); 57 | } 58 | 59 | -------------------------------------------------------------------------------- /account/server/models/system.js: -------------------------------------------------------------------------------- 1 | require('mongoose'); 2 | require('bcrypt-nodejs'); 3 | 4 | var systemSchema = mongoose.Schema({ 5 | name: String, 6 | shortName: String, 7 | systemType: String, 8 | county: String, 9 | country: String, 10 | city: String, 11 | state: String, 12 | description: String, 13 | status: String, 14 | showScreenName: Boolean, 15 | planType: {type: Number, default: 0}, 16 | userId: mongoose.Schema.Types.ObjectId, 17 | key: String 18 | }); 19 | 20 | systemSchema.methods.generateHash = function(password) { 21 | return bcrypt.hashSync(password, bcrypt.genSaltSync(8), null); 22 | }; 23 | 24 | systemSchema.methods.validPassword = function(password) { 25 | return bcrypt.compareSync(password, this.local.password); 26 | }; 27 | 28 | module.exports = mongoose.model('System', systemSchema); 29 | -------------------------------------------------------------------------------- /account/server/models/user.js: -------------------------------------------------------------------------------- 1 | // Defining a User Model in mongoose 2 | // Code modified from https://github.com/sahat/hackathon-starter 3 | const bcrypt = require("bcrypt"); 4 | const mongoose = require("mongoose"); 5 | 6 | 7 | 8 | 9 | const UserSchema = new mongoose.Schema({ 10 | local: { 11 | name: String, 12 | email: String, 13 | password: String 14 | }, 15 | email: { 16 | type: String, 17 | unique: true, 18 | lowercase: true 19 | }, 20 | password: String, 21 | screenName: { 22 | type: String, 23 | unique: true, 24 | lowercase: true 25 | }, 26 | firstName: String, 27 | lastName: String, 28 | location: String, 29 | email: String, 30 | resetPasswordToken: String, 31 | resetPasswordTTL: Date, 32 | confirmEmail: { 33 | type: Boolean, 34 | default: false 35 | }, 36 | confirmEmailToken: String, 37 | confirmEmailTTL: Date, 38 | admin: { 39 | type: Boolean, 40 | default: false 41 | }, 42 | terms: { 43 | type: Number, 44 | default: 0 45 | }, 46 | ver: { 47 | type: Number, 48 | default: 1.1 49 | }, 50 | sysCount: Number, 51 | lastLogin: { type : Date, default: Date.now } 52 | }) 53 | 54 | /** 55 | * Password hash middleware. 56 | */ 57 | /* 58 | UserSchema.pre("save", function(next) { 59 | var user = this 60 | if (!user.isModified("password")) return next() 61 | bcrypt.genSalt(8, (err, salt) => { 62 | if (err) return next(err) 63 | bcrypt.hash(user.password, salt, null, (err, hash) => { 64 | if (err) return next(err) 65 | user.password = hash 66 | user.local.password = hash; 67 | next() 68 | }) 69 | }) 70 | })*/ 71 | 72 | UserSchema.pre("save", function(next) { 73 | if(!this.isModified("password")) { 74 | return next(); 75 | } 76 | hashed = bcrypt.hashSync(this.password, 8); 77 | this.password = hashed; 78 | this.local.password = hashed; 79 | 80 | next(); 81 | }); 82 | 83 | /* 84 | Defining our own custom document instance method 85 | */ 86 | /* 87 | UserSchema.methods = { 88 | comparePassword: function(candidatePassword, cb) { 89 | bcrypt.compare(candidatePassword, this.local.password, (err, isMatch) => { 90 | if (err) return cb(err) 91 | cb(null, isMatch) 92 | }) 93 | } 94 | }*/ 95 | 96 | UserSchema.methods.comparePassword = function(plaintext, callback) { 97 | return callback(null, bcrypt.compareSync(plaintext, this.local.password)); 98 | }; 99 | 100 | 101 | /** 102 | * Statics 103 | */ 104 | UserSchema.statics = {} 105 | 106 | module.exports = mongoose.model("User", UserSchema) 107 | -------------------------------------------------------------------------------- /account/server/public/style/style.css: -------------------------------------------------------------------------------- 1 | .field { 2 | padding-top: 10px; 3 | padding-left: 5px; 4 | padding-right: 5px; 5 | } 6 | 7 | /** 8 | * The CSS shown here will not be introduced in the Quickstart guide, but shows 9 | * how you can use CSS to style your Element's container. 10 | */ 11 | .StripeElement { 12 | background-color: white; 13 | height: 40px; 14 | padding: 10px 12px; 15 | border-radius: 5px; 16 | border: 1px solid rgba(34,36,38,.15); 17 | color: rgba(0,0,0,.87); 18 | } 19 | 20 | .StripeElement--focus { 21 | border-color: #85b7d9; 22 | } 23 | 24 | .StripeElement--invalid { 25 | border-color: #fa755a; 26 | } 27 | 28 | .StripeElement--webkit-autofill { 29 | background-color: #fefde5 !important; 30 | } 31 | -------------------------------------------------------------------------------- /account/src/App/App.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Navigation from "./Navigation" 3 | import { Routes, Route } from 'react-router-dom'; 4 | 5 | import Restricted from "./Restricted" 6 | 7 | import Login from "../User/Login" 8 | import Register from "../User/Register" 9 | import Profile from "../User/Profile" 10 | import ConfirmEmail from "../User/ConfirmEmail" 11 | import WaitConfirmEmail from "../User/WaitConfirmEmail" 12 | import SentConfirmEmail from "../User/SentConfirmEmail" 13 | import SendResetPassword from "../User/SendResetPassword" 14 | import ResetPassword from "../User/ResetPassword" 15 | import Terms from "../User/Terms" 16 | 17 | const App = (params) => { 18 | return ( 19 |
20 | 21 | 22 | } /> 23 | } /> 24 | } /> 25 | } /> 26 | } /> 27 | } /> 28 | } /> 29 | } /> 30 | } /> 31 | } /> 32 | (
Miss
)} /> 33 |
34 |
35 | ) 36 | }; 37 | 38 | export default App 39 | -------------------------------------------------------------------------------- /account/src/App/Navigation.js: -------------------------------------------------------------------------------- 1 | import { Menu, Dropdown } from "semantic-ui-react"; 2 | import { useSelector, useDispatch } from 'react-redux' 3 | import { logoutUser } from "../features/user/userSlice"; 4 | 5 | const navStyle = { 6 | marginBottom: "30px" 7 | }; 8 | 9 | const Navigation = (props) => { 10 | const dispatch = useDispatch(); 11 | const { email } = useSelector((state) => state.user); 12 | 13 | const logout = event => { 14 | event.preventDefault(); 15 | dispatch(logoutUser({})); 16 | }; 17 | 18 | var profileLink = process.env.REACT_APP_ACCOUNT_SERVER + "/profile"; 19 | return ( 20 |
21 | 22 | 23 | {process.env.REACT_APP_SITE_NAME} 24 | 25 | 26 | 27 | 28 | Profile 29 | Logout 30 | 31 | 32 | 33 | 34 |
35 | ); 36 | } 37 | 38 | 39 | export default Navigation; 40 | -------------------------------------------------------------------------------- /account/src/App/Restricted.js: -------------------------------------------------------------------------------- 1 | // in src/restricted.js 2 | import React, { useEffect } from "react"; 3 | import { useSelector, useDispatch } from 'react-redux' 4 | import { authenticateUser } from "../features/user/userSlice"; 5 | import { useNavigate, useLocation } from 'react-router-dom'; 6 | /** 7 | * Higher-order component (HOC) to wrap restricted pages 8 | */ 9 | 10 | const Restricted = ({ children }) => { 11 | const { authenticated, hasAuthenticated, terms } = useSelector((state) => state.user); 12 | const dispatch = useDispatch(); 13 | const navigate = useNavigate(); 14 | const location = useLocation(); 15 | const { hash, pathname, search } = location; 16 | 17 | useEffect(() => { 18 | dispatch(authenticateUser({})); 19 | }, []); 20 | 21 | if (hasAuthenticated) { 22 | if (authenticated) { 23 | if ((pathname != "/terms") && (terms != 1.1)) { 24 | navigate("/terms") 25 | } else { 26 | return children; 27 | } 28 | } 29 | else { 30 | navigate("/login") 31 | } 32 | } 33 | return
34 | 35 | }; 36 | 37 | export default Restricted -------------------------------------------------------------------------------- /account/src/User/ConfirmEmail.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { 3 | Container, 4 | Dimmer, 5 | Loader, 6 | Button, 7 | Message, 8 | Icon 9 | } from "semantic-ui-react"; 10 | import { Link, useParams } from "react-router-dom"; 11 | import { confirmEmail } from "../features/user/userSlice"; 12 | import { useDispatch } from 'react-redux' 13 | 14 | const ConfirmEmail = (props) => { 15 | const dispatch = useDispatch(); 16 | const [loading, setLoading] = useState(true); 17 | const [confirmMessage, setConfirmMessage] = useState({ success: false, message: "" }); 18 | const { userId, token } = useParams(); 19 | 20 | 21 | const checkConfirmation = async(userId, token) => { 22 | const result = await dispatch(confirmEmail({ userId, token })).unwrap(); 23 | if (result) { 24 | setConfirmMessage(result); 25 | setLoading(false); 26 | } 27 | } 28 | 29 | useEffect(() => { 30 | checkConfirmation(userId, token); 31 | }, [userId, token]); 32 | 33 | const dimmerProps = { active: loading }; 34 | 35 | return ( 36 | 37 | 38 | Confirming Email Address 39 | 40 | 41 | {confirmMessage.success ? ( 42 | 43 | 44 | 45 | Success 46 |

You have successfully confirmed you email address.

47 | 48 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default Message; 22 | -------------------------------------------------------------------------------- /admin/src/Group/ListGroups.js: -------------------------------------------------------------------------------- 1 | import { 2 | Icon, 3 | Table 4 | } from "semantic-ui-react"; 5 | 6 | // ---------------------------------------------------- 7 | 8 | 9 | 10 | // ---------------------------------------------------- 11 | const ListGroups = (props) => { 12 | 13 | 14 | //https://stackoverflow.com/questions/36559661/how-can-i-dispatch-from-child-components-in-react-redux 15 | //https://stackoverflow.com/questions/42597602/react-onclick-pass-event-with-parameter 16 | 17 | const groups = props.groups; 18 | let groupsDisplay = []; 19 | if (groups) { 20 | // if a group gets deleted, it will still be listed in the Order array for a little. 21 | for (const id of props.order) { 22 | const group = props.groups.find( group => group._id === id ); 23 | if (group) { 24 | groupsDisplay.push(group) 25 | } 26 | } 27 | return ( 28 | 29 | 30 | 31 | 32 | Name 33 | Talkgroup Count 34 | 35 | 36 | 37 | 38 | {groupsDisplay.map((group, i) => ( 39 | 40 | 41 | {group.groupName} 42 | {group.talkgroups.length} 43 | 44 | props.editGroup(group._id)} 48 | /> props.reorderGroup(i, i-1)} 53 | /> props.reorderGroup(i, i+1)} 58 | /> props.deleteGroup(group._id)} 62 | /> 63 | 64 | 65 | 66 | ) 67 | )} 68 | 69 |
70 | 71 | ); 72 | } else { 73 | return ( 74 |
75 | ); 76 | } 77 | } 78 | 79 | export default ListGroups; 80 | -------------------------------------------------------------------------------- /admin/src/System/CallChart.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Group } from '@visx/group'; 3 | import { curveStep} from '@visx/curve'; 4 | import { LinePath } from '@visx/shape'; 5 | import { scaleLinear, scaleTime } from '@visx/scale'; 6 | import { timeParse, timeFormat } from 'd3-time-format'; 7 | import { AxisBottom, AxisLeft } from '@visx/axis'; 8 | import { Loader } from "semantic-ui-react"; 9 | 10 | const CallChart = (props) => { 11 | 12 | var callData = props.data; 13 | if (!callData) { 14 | return ( 15 | Loading 16 | ) 17 | } 18 | 19 | var callTotals = callData.callTotals; 20 | var uploadErrors = callData.uploadErrors; 21 | 22 | // Define the graph dimensions and margins 23 | const width = props.width; 24 | const height = 500; 25 | const margin = { top: 20, bottom: 40, left: 40, right: 20 }; 26 | const parseDate = timeParse("%Y%m%d"); 27 | const format = timeFormat("%b %d"); 28 | const formatDate = (date) => format(parseDate(date)); 29 | const parseTime = timeParse('%Y-%m-%dT%H:%M:%S.%LZ'); 30 | // Then we'll create some bounds 31 | const xMax = width - margin.left - margin.right; 32 | const yMax = height - margin.top - margin.bottom; 33 | 34 | // accessors 35 | //const x = d => d.x; 36 | //const y = d => d.y; 37 | 38 | var xScale, yScale; 39 | if (callData) { 40 | // We'll use some mock data from `@vx/mock-data` for this. 41 | // scales 42 | xScale = scaleTime({ 43 | range: [0, xMax], 44 | domain: [callData.minDate, callData.maxDate], 45 | tickFormat: () => (val) => formatDate(val) 46 | }); 47 | yScale = scaleLinear({ 48 | range: [yMax, 0], 49 | domain: [0, callData.maxValue], 50 | }); 51 | } 52 | 53 | // accessors 54 | const x = d => xScale(d.x); 55 | const y = d => yScale(d.y); 56 | return ( 57 | 58 | 59 | 520 ? 10 : 5} /> 60 | 61 | 70 | 78 | 79 | 80 | ); 81 | } 82 | export default CallChart; 83 | -------------------------------------------------------------------------------- /admin/src/System/CreateSystem.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | 3 | import { 4 | Container, 5 | Header, 6 | Divider, 7 | } from "semantic-ui-react"; 8 | import { useCreateSystemMutation} from '../features/api/apiSlice' 9 | import { useSelector } from 'react-redux' 10 | import { useNavigate } from 'react-router-dom'; 11 | import SystemForm from "./SystemForm"; 12 | 13 | // ---------------------------------------------------- 14 | const CreateSystem = () => { 15 | const navigate = useNavigate(); 16 | const [createSystem ] = useCreateSystemMutation(); 17 | const { screenName } = useSelector((state) => state.user); 18 | 19 | const [requestMessage, setRequestMessage] = useState(""); 20 | 21 | const handleSubmit = async (system) => { 22 | try { 23 | await createSystem(system).unwrap(); 24 | navigate("/list-systems") 25 | } catch (error) { 26 | const message = error.data.message; 27 | console.log(message); 28 | setRequestMessage(message); 29 | } 30 | } 31 | 32 | return ( 33 |
34 | 35 |
Join Us!
36 |

37 | Record the your community's radio systems and share them! All it 38 | takes is a spare computer and a cheap SDR. The software you need, 39 | along with an explanation of how to set it up, is available{" "} 40 | here. 41 |

42 |

43 | After you have the Trunk Recorder software up and running, come 44 | back here and sign-up for an account so you can share your 45 | recordings 46 |

47 |

- Luke

48 | 49 |

50 |
51 | 52 | 53 |
Create System
54 | 55 |
56 |
57 | ); 58 | 59 | } 60 | 61 | export default CreateSystem; 62 | -------------------------------------------------------------------------------- /admin/src/System/ListSystems.js: -------------------------------------------------------------------------------- 1 | import SystemCard from "./SystemCard"; 2 | import { 3 | Container, 4 | Divider, 5 | Header, 6 | Card, 7 | Icon, 8 | Button, 9 | } from "semantic-ui-react"; 10 | import { useGetSystemsQuery, } from '../features/api/apiSlice' 11 | import { useNavigate } from 'react-router-dom'; 12 | 13 | 14 | // ---------------------------------------------------- 15 | const ListSystems = () => { 16 | const { data, isSuccess } = useGetSystemsQuery(); 17 | const navigate = useNavigate(); 18 | let systems = false; 19 | if (isSuccess) { 20 | systems = data.systems; 21 | } 22 | 23 | //https://stackoverflow.com/questions/36559661/how-can-i-dispatch-from-child-components-in-react-redux 24 | //https://stackoverflow.com/questions/42597602/react-onclick-pass-event-with-parameter 25 | 26 | return ( 27 |
28 | 29 |
Radio Systems
30 | 31 | 32 | 33 | {systems && 34 | systems.map((system) => 35 | navigate("/system/" + system.shortName)}/> 36 | )} 37 | 38 |
39 |
40 | ); 41 | 42 | } 43 | 44 | export default ListSystems; 45 | -------------------------------------------------------------------------------- /admin/src/System/ResponsiveCallChart.js: -------------------------------------------------------------------------------- 1 | import CallChart from "./CallChart" 2 | import { ParentSize } from "@visx/responsive"; 3 | 4 | const ResponsiveCallChart = (props) => { 5 | 6 | return ( 7 | 8 | {({ width: w, height: h }) => { 9 | return ( 10 | 15 | ); 16 | }} 17 | 18 | ) 19 | } 20 | 21 | 22 | export default ResponsiveCallChart 23 | -------------------------------------------------------------------------------- /admin/src/System/ResponsiveErrorChart.js: -------------------------------------------------------------------------------- 1 | import ErrorChart from "./ErrorChart" 2 | import { ParentSize } from "@visx/responsive"; 3 | 4 | const ResponsiveErrorChart = (props) => { 5 | 6 | return ( 7 | 8 | {({ width: w, height: h }) => { 9 | return ( 10 | 15 | ); 16 | }} 17 | 18 | ) 19 | } 20 | 21 | export default ResponsiveErrorChart 22 | -------------------------------------------------------------------------------- /admin/src/System/SystemCard.js: -------------------------------------------------------------------------------- 1 | 2 | import { 3 | Card, 4 | Icon, 5 | Header 6 | } from "semantic-ui-react"; 7 | 8 | 9 | 10 | 11 | const SystemCard = (props) => { 12 | var location = ""; 13 | const { system } = props; 14 | 15 | 16 | if (system) { 17 | 18 | 19 | switch (system.systemType) { 20 | case "state": 21 | location = system.state; 22 | break; 23 | case "city": 24 | location = system.city + ", " + system.state; 25 | break; 26 | case "county": 27 | location = system.county + ", " + system.state; 28 | break; 29 | case "international": 30 | location = system.country; 31 | break; 32 | default: 33 | location = "unknown"; 34 | } 35 | } 36 | return ( 37 | 38 | 39 | 40 |
41 | 42 | {system.name} 43 | {system.screenName} 44 | 45 |
46 |
47 | 48 | {system.description} 49 | 50 | 51 | 52 | {location} 53 | 54 |
55 | ); 56 | 57 | } 58 | 59 | export default SystemCard; 60 | -------------------------------------------------------------------------------- /admin/src/System/UpdateSystem.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import SystemForm from "./SystemForm"; 3 | import { 4 | Container, 5 | Header 6 | } from "semantic-ui-react"; 7 | import { useUpdateSystemMutation, useGetSystemsQuery, } from '../features/api/apiSlice' 8 | import { useSelector } from 'react-redux' 9 | import { useNavigate, useParams } from 'react-router-dom'; 10 | 11 | const UpdateSystem = (props) => { 12 | 13 | const { screenName } = useSelector((state) => state.user); 14 | const { shortName } = useParams(); 15 | const { data: systemsData, isSuccess: isSystemsSuccess } = useGetSystemsQuery(); 16 | const [updateSystem] = useUpdateSystemMutation(); 17 | const [requestMessage, setRequestMessage] = useState(""); 18 | 19 | const navigate = useNavigate(); 20 | let system = false; 21 | if (isSystemsSuccess) { 22 | system = systemsData.systems.find(sys => sys.shortName === shortName); 23 | } 24 | 25 | 26 | const handleSystemSubmit = async (system) => { 27 | try { 28 | await updateSystem(system).unwrap(); 29 | navigate("/system/" + shortName) 30 | } catch (error) { 31 | const message = error.data.message; 32 | console.log(message); 33 | setRequestMessage(message); 34 | } 35 | } 36 | 37 | 38 | return ( 39 | 40 |
Update System
41 | 42 |
43 | ) 44 | 45 | } 46 | 47 | export default UpdateSystem 48 | -------------------------------------------------------------------------------- /admin/src/Talkgroups/ListTalkgroups.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { 3 | Table 4 | } from "semantic-ui-react"; 5 | 6 | // ---------------------------------------------------- 7 | 8 | 9 | 10 | // ---------------------------------------------------- 11 | class ListTalkgroups extends Component { 12 | 13 | //https://stackoverflow.com/questions/36559661/how-can-i-dispatch-from-child-components-in-react-redux 14 | //https://stackoverflow.com/questions/42597602/react-onclick-pass-event-with-parameter 15 | render() { 16 | const talkgroups = this.props.talkgroups; 17 | if (talkgroups) { 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | Decimal 25 | Alpha Tag 26 | Description 27 | 28 | 29 | 30 | {talkgroups.map((talkgroup, i) => ( 31 | 32 | {talkgroup.num} 33 | {talkgroup.alpha} 34 | {talkgroup.description} 35 | 36 | ))} 37 | 38 |
39 | 40 | ); 41 | } else { 42 | return ( 43 |
44 | ); 45 | } 46 | } 47 | } 48 | 49 | export default ListTalkgroups; 50 | -------------------------------------------------------------------------------- /admin/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Provider } from 'react-redux' 3 | import { createRoot } from 'react-dom/client'; 4 | import { BrowserRouter } from "react-router-dom"; 5 | import setupStore from './redux-router/configureStore' 6 | import 'semantic-ui-css/semantic.min.css' 7 | import App from "./App/App" 8 | 9 | 10 | const store = setupStore(/* provide initial state if any */) 11 | 12 | 13 | 14 | const container = document.getElementById('root'); 15 | const root = createRoot(container); 16 | root.render( 17 | 18 | 19 | 20 | 21 | 22 | ) 23 | 24 | -------------------------------------------------------------------------------- /admin/src/redux-router/configureStore.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit' 2 | import { userReducer } from '../features/user/userSlice' 3 | import { apiSlice } from "../features/api/apiSlice" 4 | 5 | const setupStore = (preloadedState) => { 6 | const store = configureStore({ 7 | reducer: { 8 | user: userReducer, 9 | [apiSlice.reducerPath]: apiSlice.reducer, 10 | }, 11 | middleware: (getDefaultMiddleware) => 12 | // adding the api middleware enables caching, invalidation, polling and other features of `rtk-query` 13 | getDefaultMiddleware().concat(apiSlice.middleware), 14 | preloadedState, 15 | }) 16 | 17 | return store 18 | } 19 | 20 | export default setupStore; -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | #uploads 12 | upload/* 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directory 30 | node_modules 31 | assets 32 | lib 33 | 34 | # Optional npm cache directory 35 | .npm 36 | 37 | # Optional REPL history 38 | .node_repl_history 39 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # docker build -t smartnet-player . 2 | # do a `git pull` in smartnet-player to update 3 | 4 | FROM node:22-bookworm 5 | 6 | # RUN echo deb https://www.deb-multimedia.org bullseye main non-free \ 7 | # >>/etc/apt/sources.list && apt-get update -oAcquire::AllowInsecureRepositories=true &&\ 8 | # apt-get install -y --force-yes deb-multimedia-keyring && \ 9 | 10 | RUN apt-get update && \ 11 | apt-get install -y ffmpeg cron python3 build-essential g++ 12 | 13 | ENV HOME=/home/app 14 | 15 | 16 | RUN mkdir -p /home/app 17 | COPY package.json /tmp 18 | RUN cd /tmp && npm --unsafe-perm install -g node-gyp && npm --unsafe-perm install 19 | RUN mkdir -p /home/app/upload && cp -a /tmp/node_modules /home/app 20 | 21 | WORKDIR $HOME/ 22 | COPY . $HOME/ 23 | RUN npm install -g nodemon 24 | # Run the command on container startup 25 | CMD node index.js 26 | -------------------------------------------------------------------------------- /backend/agents/honeycomb-tracing.js: -------------------------------------------------------------------------------- 1 | // tracing.js 2 | 'use strict'; 3 | 4 | const { HoneycombSDK } = require('@honeycombio/opentelemetry-node'); 5 | const { 6 | getNodeAutoInstrumentations, 7 | } = require('@opentelemetry/auto-instrumentations-node'); 8 | 9 | // uses the HONEYCOMB_API_KEY and OTEL_SERVICE_NAME environment variables 10 | const sdk = new HoneycombSDK({ 11 | sampleRate: 20, 12 | instrumentations: [ 13 | getNodeAutoInstrumentations({ 14 | // we recommend disabling fs autoinstrumentation since it can be noisy 15 | // and expensive during startup 16 | '@opentelemetry/instrumentation-fs': { 17 | enabled: false, 18 | }, 19 | }), 20 | ], 21 | }); 22 | 23 | sdk.start(); -------------------------------------------------------------------------------- /backend/config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "uploadDirectory": "/home/app/upload", 3 | "mediaDirectory": "/home/app/media" 4 | } 5 | -------------------------------------------------------------------------------- /backend/controllers/groups.js: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /backend/controllers/stats.js: -------------------------------------------------------------------------------- 1 | var schedule = require('node-schedule'); 2 | var sysStats = require("../sys_stats"); 3 | const util = require('util'); 4 | 5 | exports.init_stats = function() { 6 | sysStats.initStats(); 7 | } 8 | 9 | exports.get_stats = function(req, res) { 10 | var short_name = req.params.shortName.toLowerCase(); 11 | 12 | var callTotals = sysStats.callTotals(short_name); 13 | var talkgroupStats = sysStats.talkgroupStats(short_name); 14 | var uploadErrors = sysStats.uploadErrors(short_name); 15 | let decodeErrorsFreq = sysStats.decodeErrorsFreq(short_name); 16 | 17 | if (callTotals && talkgroupStats) { 18 | res.contentType('json'); 19 | res.send(JSON.stringify({ 20 | callTotals: callTotals, 21 | talkgroupStats: talkgroupStats, 22 | uploadErrors: uploadErrors, 23 | decodeErrorsFreq: decodeErrorsFreq 24 | })); 25 | } else { 26 | console.log("ShortName didn't exist for stats: " + util.inspect(req.params)); 27 | res.status(500); 28 | res.send("ShortName didn't exist for stats"); 29 | } 30 | } 31 | 32 | 33 | var statSched = schedule.scheduleJob('*/15 * * * *', function() { 34 | sysStats.shiftStats(); 35 | }); 36 | 37 | -------------------------------------------------------------------------------- /backend/controllers/talkgroups.js: -------------------------------------------------------------------------------- 1 | const Talkgroup = require("../models/talkgroup"); 2 | const Group = require("../models/group"); 3 | 4 | let groups = {}; 5 | let talkgroups = {}; 6 | 7 | async function load_talkgroups(shortName) { 8 | const tg_results = await Talkgroup.find({ 'shortName': shortName }).catch(err => { 9 | console.error(err); 10 | res.status(500); 11 | res.json({ success: false, message: err }); 12 | return; 13 | }); 14 | 15 | let temp = {} 16 | for (var tg in tg_results) { 17 | var talkgroup = { _id: tg_results[tg]._id, num: tg_results[tg].num, alpha: tg_results[tg].alpha, description: tg_results[tg].description } 18 | temp[tg_results[tg].num] = talkgroup; 19 | } 20 | 21 | return temp; 22 | } 23 | 24 | exports.get_talkgroups = async function (req, res) { 25 | const shortName = req.params.shortName.toLowerCase(); 26 | /* 27 | if (talkgroups.hasOwnProperty(shortName)) { 28 | talkgroups = talkgroups[shortName]; 29 | } else { 30 | }*/ 31 | 32 | let talkgroups = await load_talkgroups(shortName); 33 | 34 | res.contentType('json'); 35 | res.send(JSON.stringify({ 36 | talkgroups: talkgroups 37 | })); 38 | } 39 | 40 | 41 | exports.get_groups = async function (req, res) { 42 | const shortName = req.params.shortName.toLowerCase(); 43 | 44 | const grp_results = await Group.find({ shortName: req.params.shortName.toLowerCase() }).sort("position").catch(err => { 45 | console.error(err); 46 | res.status(500); 47 | res.json({ success: false, message: err }); 48 | return; 49 | }); 50 | 51 | res.contentType('json'); 52 | res.send(JSON.stringify(grp_results)); 53 | } 54 | -------------------------------------------------------------------------------- /backend/db.js: -------------------------------------------------------------------------------- 1 | var Call = require("./models/call"); 2 | var Event = require("./models/event"); 3 | var Podcast = require("./models/podcast"); 4 | 5 | exports.cleanOldEvents = async function() { 6 | var date = new Date(); 7 | Event.bulkWrite([ 8 | { 9 | deleteMany: { 10 | filter: { expireTime: {$lt: date} } 11 | } 12 | } 13 | ]).then(res => { 14 | // Prints "1 1 1" 15 | console.log("Removed " + res.deletedCount + " Podcasts"); 16 | }); 17 | } 18 | 19 | exports.cleanOldPodcasts = async function() { 20 | var date = new Date(); 21 | Podcast.bulkWrite([ 22 | { 23 | deleteMany: { 24 | filter: { expireTime: {$lt: date} } 25 | } 26 | } 27 | ]).then(res => { 28 | // Prints "1 1 1" 29 | console.log("Removed " + res.deletedCount + " Events"); 30 | }); 31 | } 32 | 33 | 34 | 35 | exports.cleanOldCalls = async function() { 36 | var date = new Date(); 37 | date.setMonth(date.getMonth() - 1); 38 | Call.bulkWrite([ 39 | { 40 | deleteMany: { 41 | filter: { time: {$lt: date} } 42 | } 43 | } 44 | ]).then(res => { 45 | // Prints "1 1 1" 46 | console.log("Removed " + res.deletedCount + " Calls older than " + date); 47 | }); 48 | } -------------------------------------------------------------------------------- /backend/models/call.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'); 2 | const callSchema = require('./callSchema'); 3 | /* 4 | { "_id" : ObjectId("5bbc148de26adf00068a0076"), "shortName" : "dcfd", "talkgroupNum" : 11619, "objectKey" : "dcfd-11619-1539052672.m4a", "endpoint" : "nyc3.digitaloceanspaces.com", "bucket" : "openmhz", "time" : ISODate("2018-10-09T02:37:52Z"), "name" : "11619-1539052672.m4a", "freq" : 856987500, "url" : "https://media.openmhz.com/dcfd/2018/10/9/11619-1539052672.m4a", "emergency" : 0, "path" : "/dcfd/2018/10/9/", "srcList" : [ { "pos" : 0, "src" : 1116743 }, { "pos" : 1.8, "src" : 1102467 }, { "pos" : 3.6, "src" : 1116743 } ], "freqList" : [ { "pos" : 0, "freq" : 856987500, "len" : 51744, "errors" : 170, "spikes" : 29 } ], "len" : 6.142 } 5 | */ 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | module.exports = mongoose.model('Call', callSchema); 14 | -------------------------------------------------------------------------------- /backend/models/callSchema.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'); 2 | 3 | var srcSchema = mongoose.Schema({ pos: Number, src: String }); 4 | 5 | const callSchema = mongoose.Schema({ 6 | talkgroupNum: Number, 7 | shortName: String, 8 | objectKey: String, 9 | endpoint: String, 10 | bucket: String, 11 | time: Date, 12 | name: String, 13 | freq: Number, 14 | errorCount: Number, 15 | spikeCount: Number, 16 | url: String, 17 | emergency: Boolean, 18 | path: String, 19 | len: Number, 20 | patches: [Number], 21 | star: { 22 | type: Number, 23 | default: 0 24 | }, 25 | srcList: [srcSchema] 26 | }); 27 | 28 | module.exports = callSchema; -------------------------------------------------------------------------------- /backend/models/event.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'); 2 | const frozenCallSchema = require('./frozenCallSchema'); 3 | 4 | 5 | var eventSchema = mongoose.Schema({ 6 | title: String, 7 | description: String, 8 | expireTime: Date, 9 | startTime: Date, 10 | endTime: Date, 11 | downloadUrl: String, 12 | podcastUrl: String, 13 | shortNames: [String], 14 | calls: [frozenCallSchema], 15 | numCalls: Number 16 | }, 17 | { 18 | timestamps: true 19 | }); 20 | 21 | // add virtual if You want 22 | eventSchema.virtual('callCount').get(function () { 23 | return this.calls.length; 24 | }); 25 | module.exports = mongoose.model('Event', eventSchema); 26 | -------------------------------------------------------------------------------- /backend/models/frozenCall.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'); 2 | const frozenCallSchema = require('./frozenCallSchema'); 3 | 4 | module.exports = mongoose.model('FrozenCall', frozenCallSchema); -------------------------------------------------------------------------------- /backend/models/frozenCallSchema.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'); 2 | const callSchema = require('./callSchema'); 3 | 4 | var frozenCallSchema = mongoose.Schema(); 5 | 6 | frozenCallSchema.add(callSchema).add({ 7 | systemName: String, 8 | systemDescription: String, 9 | talkgroupDescription: String, 10 | talkgroupAlpha: String, 11 | 12 | }); 13 | 14 | module.exports = frozenCallSchema; -------------------------------------------------------------------------------- /backend/models/group.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'); 2 | 3 | var groupSchema = mongoose.Schema({ 4 | shortName: String, 5 | groupName: String, 6 | groupId: String, 7 | position: Number, 8 | talkgroups: [Number] 9 | }); 10 | 11 | 12 | module.exports = mongoose.model('Group', groupSchema); 13 | -------------------------------------------------------------------------------- /backend/models/permission.js: -------------------------------------------------------------------------------- 1 | // Defining a User Model in mongoose 2 | // Code modified from https://github.com/sahat/hackathon-starter 3 | 4 | var mongoose = require("mongoose"); 5 | 6 | 7 | 8 | const PermissionSchema = new mongoose.Schema({ 9 | userId: mongoose.Schema.Types.ObjectId, 10 | systemId: mongoose.Schema.Types.ObjectId, 11 | shortName: String, 12 | role: { 13 | type: Number, 14 | default: 0 15 | }, 16 | ver: { 17 | type: Number, 18 | default: 1.2 19 | } 20 | }) 21 | 22 | 23 | 24 | module.exports=mongoose.model("Permission", PermissionSchema) 25 | -------------------------------------------------------------------------------- /backend/models/podcast.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'); 2 | 3 | 4 | 5 | var podcastSchema = mongoose.Schema({ 6 | title: String, 7 | description: String, 8 | expireTime: Date, 9 | startTime: Date, 10 | endTime: Date, 11 | downloadUrl: String, 12 | systems: [String], 13 | numCalls: Number, 14 | eventUrl: String, 15 | len: Number 16 | }, 17 | { 18 | timestamps: true 19 | }); 20 | 21 | 22 | module.exports = mongoose.model('Podcast', podcastSchema); 23 | -------------------------------------------------------------------------------- /backend/models/star.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'); 2 | var Call = require('./call.js') 3 | 4 | var starSchema = mongoose.Schema({ 5 | talkgroupNum: Number, 6 | shortName: String, 7 | callId: {type: mongoose.Schema.Types.ObjectId, ref: 'Call'}, 8 | time: Date, 9 | star: { 10 | type: Number, 11 | default: 0 12 | } 13 | }); 14 | 15 | 16 | module.exports = mongoose.model('Star', starSchema); 17 | -------------------------------------------------------------------------------- /backend/models/system.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'); 2 | const systemSchema = require('./systemSchema'); 3 | 4 | 5 | exports.systemSchema = systemSchema; 6 | module.exports = mongoose.model('System', systemSchema); 7 | -------------------------------------------------------------------------------- /backend/models/systemSchema.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'); 2 | var bcrypt = require('bcrypt-nodejs'); 3 | var User = require('./user'); 4 | 5 | const systemSchema = mongoose.Schema({ 6 | name: String, 7 | shortName: String, 8 | systemType: String, 9 | county: String, 10 | country: String, 11 | city: String, 12 | state: String, 13 | description: String, 14 | status: String, 15 | showScreenName: Boolean, 16 | allowContact: { 17 | type: Boolean, 18 | default: true 19 | }, 20 | callAvg: Number, 21 | callCount: Number, 22 | ignoreUnknownTalkgroup : Boolean, 23 | active: {type: Boolean, default: false}, 24 | lastActive: Date, 25 | userId: {type: mongoose.Schema.Types.ObjectId, ref: 'User'}, 26 | key: String 27 | }); 28 | 29 | systemSchema.methods.generateHash = function(password) { 30 | return bcrypt.hashSync(password, bcrypt.genSaltSync(8), null); 31 | }; 32 | 33 | systemSchema.methods.validPassword = function(password) { 34 | return bcrypt.compareSync(password, this.local.password); 35 | }; 36 | 37 | module.exports = systemSchema; -------------------------------------------------------------------------------- /backend/models/system_stat.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'); 2 | 3 | let decodeErrorSchema = mongoose.Schema({ 4 | totalLen: Number, 5 | errors: Number, 6 | spikes: Number, 7 | errorHistory: [Number], 8 | spikeHistory: [Number], 9 | }) 10 | 11 | let talkgroupStatsSchema = mongoose.Schema({ 12 | calls: Number, 13 | totalLen: Number, 14 | callCountHistory: [Number], 15 | callAvgLenHistory: [Number] 16 | }) 17 | var systemStatSchema = mongoose.Schema({ 18 | callTotals: [Number], 19 | uploadErrors: [Number], 20 | decodeErrorsFreq: { 21 | type: Map, 22 | of: decodeErrorSchema 23 | }, 24 | talkgroupStats: { 25 | type: Map, 26 | of: talkgroupStatsSchema 27 | }, 28 | shortName: String 29 | }, { collection: 'system_stats' }); 30 | 31 | 32 | module.exports = mongoose.model('SystemStat', systemStatSchema); 33 | -------------------------------------------------------------------------------- /backend/models/talkgroup.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'); 2 | const talkgroupSchema = require('./talkgroupSchema'); 3 | 4 | module.exports = mongoose.model('Talkgroup', talkgroupSchema); 5 | -------------------------------------------------------------------------------- /backend/models/talkgroupSchema.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'); 2 | 3 | const talkgroupSchema = mongoose.Schema({ 4 | shortName: String, 5 | userId: String, 6 | num: Number, 7 | mode: String, 8 | alpha: String, 9 | description: String, 10 | tag: String, 11 | group: String, 12 | priority: Number 13 | }); 14 | 15 | module.exports = talkgroupSchema; -------------------------------------------------------------------------------- /backend/models/user.js: -------------------------------------------------------------------------------- 1 | // Defining a User Model in mongoose 2 | // Code modified from https://github.com/sahat/hackathon-starter 3 | var bcrypt = require("bcrypt-nodejs"); 4 | var mongoose = require("mongoose"); 5 | var crypto = require("crypto"); 6 | 7 | 8 | 9 | const UserSchema = new mongoose.Schema({ 10 | local: { 11 | name: String, 12 | email: String, 13 | password: String 14 | }, 15 | email: { 16 | type: String, 17 | unique: true, 18 | lowercase: true 19 | }, 20 | password: String, 21 | screenName: { 22 | type: String, 23 | unique: true, 24 | lowercase: true 25 | }, 26 | firstName: String, 27 | lastName: String, 28 | location: String, 29 | email: String, 30 | resetPasswordToken: String, 31 | resetPasswordTTL: Date, 32 | confirmEmail: { 33 | type: Boolean, 34 | default: false 35 | }, 36 | confirmEmailToken: String, 37 | confirmEmailTTL: Date, 38 | admin: { 39 | type: Boolean, 40 | default: false 41 | }, 42 | terms: { 43 | type: Number, 44 | default: 0 45 | }, 46 | ver: { 47 | type: Number, 48 | default: 1.2 49 | }, 50 | sysCount: Number, 51 | lastLogin: { type : Date, default: Date.now } 52 | }) 53 | 54 | /** 55 | * Password hash middleware. 56 | */ 57 | UserSchema.pre("save", function(next) { 58 | var user = this 59 | if (!user.isModified("password")) return next() 60 | bcrypt.genSalt(8, (err, salt) => { 61 | if (err) return next(err) 62 | bcrypt.hash(user.password, salt, null, (err, hash) => { 63 | if (err) return next(err) 64 | user.password = hash; 65 | user.local.password = hash; 66 | next() 67 | }) 68 | }) 69 | }) 70 | 71 | /* 72 | Defining our own custom document instance method 73 | */ 74 | UserSchema.methods = { 75 | comparePassword: function(candidatePassword, cb) { 76 | bcrypt.compare(candidatePassword, this.local.password, (err, isMatch) => { 77 | if (err) return cb(err) 78 | cb(null, isMatch) 79 | }) 80 | } 81 | } 82 | 83 | /** 84 | * Statics 85 | */ 86 | UserSchema.statics = {} 87 | 88 | module.exports=mongoose.model("User", UserSchema) 89 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trunk-server-backend", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "dependencies": { 6 | "@aws-sdk/client-s3": "^3.670.0", 7 | "@aws-sdk/credential-provider-process": "^3.664.0", 8 | "@aws-sdk/credential-providers": "^3.670.0", 9 | "@opentelemetry/auto-instrumentations-node": "^0.50.2", 10 | "@opentelemetry/resources": "^1.26.0", 11 | "@smithy/node-http-handler": "^3.2.4", 12 | "adm-zip": "^0.5.16", 13 | "axios": "1.7.7", 14 | "bcrypt-nodejs": "0.0.3", 15 | "body-parser": "1.20.3", 16 | "bson": "6.8.0", 17 | "csv": "6.3.10", 18 | "csv-write-stream": "^2.0.0", 19 | "express": "4.21.1", 20 | "ffprobe": "^1.1.2", 21 | "ffprobe-static": "3.1.0", 22 | "mongodb": "6.9.0", 23 | "mongoose": "8.7.1", 24 | "multer": "1.4.4", 25 | "node-ffprobe": "https://github.com/ListenerApproved/node-ffprobe", 26 | "node-mailjet": "6.0.6", 27 | "node-schedule": "2.1.1", 28 | "passport-local": "1.0.0", 29 | "socket.io": "^4.8.0", 30 | "twitter-lite": "1.1.0" 31 | }, 32 | "scripts": { 33 | "test": "mocha --reporter spec" 34 | }, 35 | "repository": { 36 | "type": "git", 37 | "url": "git+ssh://git@gitlab.com/trunk-server/trunk-server-backend.git" 38 | }, 39 | "author": "", 40 | "license": "ISC", 41 | "bugs": { 42 | "url": "https://gitlab.com/trunk-server/trunk-server-backend/issues" 43 | }, 44 | "homepage": "https://gitlab.com/trunk-server/trunk-server-backend#README", 45 | "directories": { 46 | "test": "test" 47 | }, 48 | "description": "" 49 | } 50 | -------------------------------------------------------------------------------- /backend/public/css/player.css: -------------------------------------------------------------------------------- 1 | /******************************* 2 | Global 3 | *******************************/ 4 | 5 | /*------------------- 6 | Sidebar 7 | --------------------*/ 8 | 9 | #player .sidebar { 10 | overflow: visible; 11 | } 12 | 13 | /*------------------- 14 | Grid 15 | --------------------*/ 16 | 17 | 18 | 19 | .now-playing { 20 | background-color: azure; 21 | } 22 | #call-table { 23 | 24 | 25 | } 26 | 27 | .right.rail { 28 | margin-top: 25px; 29 | width: 400px; 30 | } 31 | 32 | #calls { 33 | margin-left: 25px; 34 | margin-right: 25px; 35 | margin-bottom: 150px; 36 | 37 | } 38 | 39 | #sidebar { 40 | margin-top: 25px; 41 | } 42 | 43 | .live-call { 44 | font-weight: bold; 45 | } 46 | 47 | .loading { 48 | 49 | text-align: center; 50 | font-weight: bold; 51 | } 52 | 53 | 54 | #player .container { 55 | position: relative; 56 | width: 600px; 57 | margin-left: 4em; 58 | } 59 | 60 | 61 | .ui.sticky.fixed.top { 62 | 63 | margin-top: 25px !important; 64 | } 65 | 66 | #call-header { 67 | 68 | position: -webkit-sticky; 69 | position: -moz-sticky; 70 | position: -ms-sticky; 71 | position: -o-sticky; 72 | position: sticky; 73 | top: 0; 74 | } 75 | 76 | 77 | /******************************* 78 | Responsive 79 | *******************************/ 80 | /* Defaults */ 81 | 82 | .main.container{ 83 | margin-left: 3em !important; 84 | margin-right: 3em !important; 85 | width: auto !important; 86 | max-width: 960px !important; 87 | } 88 | 89 | .main.container { 90 | margin-right: 387px !important; 91 | } 92 | 93 | /* Rail Default */ 94 | .main.container > .right.rail 95 | { 96 | margin-left: 3em; 97 | padding-top: 2em; 98 | width: 319px; 99 | } -------------------------------------------------------------------------------- /backend/public/filler/10silence.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/backend/public/filler/10silence.m4a -------------------------------------------------------------------------------- /backend/public/filler/30silence.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/backend/public/filler/30silence.m4a -------------------------------------------------------------------------------- /backend/public/filler/60silence.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/backend/public/filler/60silence.m4a -------------------------------------------------------------------------------- /backend/public/img/ajax-loader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/backend/public/img/ajax-loader.gif -------------------------------------------------------------------------------- /backend/public/semantic-ui/components/ad.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * # Semantic UI 2.0.0 - Ad 3 | * http://github.com/semantic-org/semantic-ui/ 4 | * 5 | * 6 | * Copyright 2013 Contributors 7 | * Released under the MIT license 8 | * http://opensource.org/licenses/MIT 9 | * 10 | */.ui.ad{display:block;overflow:hidden;margin:1em 0}.ui.ad:first-child,.ui.ad:last-child{margin:0}.ui.ad iframe{margin:0;padding:0;border:none;overflow:hidden}.ui.leaderboard.ad{width:728px;height:90px}.ui[class*="medium rectangle"].ad{width:300px;height:250px}.ui[class*="large rectangle"].ad{width:336px;height:280px}.ui[class*="half page"].ad{width:300px;height:600px}.ui.square.ad{width:250px;height:250px}.ui[class*="small square"].ad{width:200px;height:200px}.ui[class*="small rectangle"].ad{width:180px;height:150px}.ui[class*="vertical rectangle"].ad{width:240px;height:400px}.ui.button.ad{width:120px;height:90px}.ui[class*="square button"].ad{width:125px;height:125px}.ui[class*="small button"].ad{width:120px;height:60px}.ui.skyscraper.ad{width:120px;height:600px}.ui[class*="wide skyscraper"].ad{width:160px}.ui.banner.ad{width:468px;height:60px}.ui[class*="vertical banner"].ad{width:120px;height:240px}.ui[class*="top banner"].ad{width:930px;height:180px}.ui[class*="half banner"].ad{width:234px;height:60px}.ui[class*="large leaderboard"].ad{width:970px;height:90px}.ui.billboard.ad{width:970px;height:250px}.ui.panorama.ad{width:980px;height:120px}.ui.netboard.ad{width:580px;height:400px}.ui[class*="large mobile banner"].ad{width:320px;height:100px}.ui[class*="mobile leaderboard"].ad{width:320px;height:50px}.ui.mobile.ad{display:none}@media only screen and (max-width:767px){.ui.mobile.ad{display:block}}.ui.centered.ad{margin-left:auto;margin-right:auto}.ui.test.ad{position:relative;background:#545454}.ui.test.ad:after{position:absolute;top:50%;left:50%;width:100%;text-align:center;-webkit-transform:translateX(-50%) translateY(-50%);-ms-transform:translateX(-50%) translateY(-50%);transform:translateX(-50%) translateY(-50%);content:'Ad';color:#fff;font-size:1em;font-weight:700}.ui.mobile.test.ad:after{font-size:.85714286em}.ui.test.ad[data-text]:after{content:attr(data-text)} -------------------------------------------------------------------------------- /backend/public/semantic-ui/components/breadcrumb.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * # Semantic UI 2.0.0 - Breadcrumb 3 | * http://github.com/semantic-org/semantic-ui/ 4 | * 5 | * 6 | * Copyright 2015 Contributors 7 | * Released under the MIT license 8 | * http://opensource.org/licenses/MIT 9 | * 10 | */.ui.breadcrumb{line-height:1;margin:1em 0;display:inline-block;vertical-align:middle}.ui.breadcrumb:first-child{margin-top:0}.ui.breadcrumb:last-child{margin-bottom:0}.ui.breadcrumb .divider{display:inline-block;opacity:.7;margin:0 .21428571rem;font-size:.92857143em;color:rgba(0,0,0,.4);vertical-align:baseline}.ui.breadcrumb a{color:#4183c4}.ui.breadcrumb a:hover{color:#1e70bf}.ui.breadcrumb .icon.divider{font-size:.85714286em;vertical-align:baseline}.ui.breadcrumb a.section{cursor:pointer}.ui.breadcrumb .section{display:inline-block;margin:0;padding:0}.ui.breadcrumb.segment{display:inline-block;padding:.71428571em 1em}.ui.breadcrumb .active.section{font-weight:700}.ui.mini.breadcrumb{font-size:.71428571rem}.ui.tiny.breadcrumb{font-size:.85714286rem}.ui.small.breadcrumb{font-size:.92857143rem}.ui.breadcrumb{font-size:1rem}.ui.large.breadcrumb{font-size:1.14285714rem}.ui.big.breadcrumb{font-size:1.28571429rem}.ui.huge.breadcrumb{font-size:1.42857143rem}.ui.massive.breadcrumb{font-size:1.71428571rem} -------------------------------------------------------------------------------- /backend/public/semantic-ui/components/container.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * # Semantic UI 2.0.0 - Container 3 | * http://github.com/semantic-org/semantic-ui/ 4 | * 5 | * 6 | * Copyright 2015 Contributors 7 | * Released under the MIT license 8 | * http://opensource.org/licenses/MIT 9 | * 10 | */.ui.container{display:block;max-width:100%!important}@media only screen and (max-width:767px){.ui.container{width:auto;margin-left:1em!important;margin-right:1em!important}.ui.grid.container{width:auto!important}}@media only screen and (min-width:768px) and (max-width:991px){.ui.container{width:723px;margin-left:auto!important;margin-right:auto!important}.ui.grid.container{width:calc(723px + 2em)!important}}@media only screen and (min-width:992px) and (max-width:1199px){.ui.container{width:933px;margin-left:auto!important;margin-right:auto!important}.ui.grid.container{width:calc(933px + 2em)!important}}@media only screen and (min-width:1200px){.ui.container{width:1127px;margin-left:auto!important;margin-right:auto!important}.ui.grid.container{width:calc(1127px + 2em)!important}}.ui.text.container{font-family:Lato,'Helvetica Neue',Arial,Helvetica,sans-serif;max-width:700px!important;line-height:1.5;font-size:1.14285714rem}.ui.fluid.container{width:100%}.ui[class*="left aligned"].container{text-align:left}.ui[class*="center aligned"].container{text-align:center}.ui[class*="right aligned"].container{text-align:right}.ui.justified.container{text-align:justify;-webkit-hyphens:auto;-moz-hyphens:auto;-ms-hyphens:auto;hyphens:auto} -------------------------------------------------------------------------------- /backend/public/semantic-ui/components/embed.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * # Semantic UI 2.0.0 - Video 3 | * http://github.com/semantic-org/semantic-ui/ 4 | * 5 | * 6 | * Copyright 2015 Contributors 7 | * Released under the MIT license 8 | * http://opensource.org/licenses/MIT 9 | * 10 | */.ui.embed{position:relative;max-width:100%;height:0;overflow:hidden;background:#dcddde;padding-bottom:56.25%}.ui.embed embed,.ui.embed iframe,.ui.embed object{position:absolute;border:none;width:100%;height:100%;top:0;left:0;margin:0;padding:0}.ui.embed>.embed{display:none}.ui.embed>.placeholder{position:absolute;cursor:pointer;top:0;left:0;display:block;width:100%;height:100%;background-color:radial-gradient(transparent 45%,rgba(0,0,0,.3))}.ui.embed>.icon{cursor:pointer;position:absolute;top:0;left:0;width:100%;height:100%;z-index:2}.ui.embed>.icon:after{position:absolute;top:0;left:0;width:100%;height:100%;z-index:3;content:'';background:-webkit-radial-gradient(transparent 45%,rgba(0,0,0,.3));background:radial-gradient(transparent 45%,rgba(0,0,0,.3));opacity:.5;-webkit-transition:opacity .5s ease;transition:opacity .5s ease}.ui.embed>.icon:before{position:absolute;top:50%;left:50%;-webkit-transform:translateX(-50%) translateY(-50%);-ms-transform:translateX(-50%) translateY(-50%);transform:translateX(-50%) translateY(-50%);color:#fff;font-size:6rem;text-shadow:0 2px 10px rgba(34,36,38,.2);-webkit-transition:opacity .5s ease,color .5s ease;transition:opacity .5s ease,color .5s ease;z-index:10}.ui.embed .icon:hover:after{background:-webkit-radial-gradient(transparent 45%,rgba(0,0,0,.3));background:radial-gradient(transparent 45%,rgba(0,0,0,.3));opacity:1}.ui.embed .icon:hover:before{color:#fff}.ui.active.embed>.icon,.ui.active.embed>.placeholder{display:none}.ui.active.embed>.embed{display:block}.ui.square.embed{padding-bottom:100%}.ui[class*="4:3"].embed{padding-bottom:75%}.ui[class*="16:9"].embed{padding-bottom:56.25%}.ui[class*="21:9"].embed{padding-bottom:42.85714286%} -------------------------------------------------------------------------------- /backend/public/semantic-ui/components/nag.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * # Semantic UI 2.0.0 - Nag 3 | * http://github.com/semantic-org/semantic-ui/ 4 | * 5 | * 6 | * Copyright 2015 Contributors 7 | * Released under the MIT license 8 | * http://opensource.org/licenses/MIT 9 | * 10 | */.ui.nag{display:none;opacity:.95;position:relative;top:0;left:0;z-index:999;min-height:0;width:100%;margin:0;padding:.75em 1em;background:#555;box-shadow:0 1px 2px 0 rgba(0,0,0,.2);font-size:1rem;text-align:center;color:rgba(0,0,0,.87);border-radius:0 0 .28571429rem .28571429rem;-webkit-transition:.2s background ease;transition:.2s background ease}a.ui.nag{cursor:pointer}.ui.nag>.title{display:inline-block;margin:0 .5em;color:#fff}.ui.nag>.close.icon{cursor:pointer;opacity:.4;position:absolute;top:50%;right:1em;font-size:1em;margin:-.5em 0 0;color:#fff;-webkit-transition:opacity .2s ease;transition:opacity .2s ease}.ui.nag:hover{background:#555;opacity:1}.ui.nag .close:hover{opacity:1}.ui.overlay.nag{position:absolute;display:block}.ui.fixed.nag{position:fixed}.ui.bottom.nag,.ui.bottom.nags{border-radius:.28571429rem .28571429rem 0 0;top:auto;bottom:0}.ui.inverted.nag,.ui.inverted.nags .nag{background-color:#f3f4f5;color:rgba(0,0,0,.85)}.ui.inverted.nag .close,.ui.inverted.nag .title,.ui.inverted.nags .nag .close,.ui.inverted.nags .nag .title{color:rgba(0,0,0,.4)}.ui.nags .nag{border-radius:0!important}.ui.nags .nag:last-child{border-radius:0 0 .28571429rem .28571429rem}.ui.bottom.nags .nag:last-child{border-radius:.28571429rem .28571429rem 0 0} -------------------------------------------------------------------------------- /backend/public/semantic-ui/components/rail.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * # Semantic UI 2.0.0 - Rail 3 | * http://github.com/semantic-org/semantic-ui/ 4 | * 5 | * 6 | * Copyright 2015 Contributors 7 | * Released under the MIT license 8 | * http://opensource.org/licenses/MIT 9 | * 10 | */.ui.rail{position:absolute;top:0;width:300px;height:100%;font-size:1rem}.ui.left.rail{left:auto;right:100%;padding:0 2rem 0 0;margin:0 2rem 0 0}.ui.right.rail{left:100%;right:auto;padding:0 0 0 2rem;margin:0 0 0 2rem}.ui.left.internal.rail{left:0;right:auto;padding:0 0 0 2rem;margin:0 0 0 2rem}.ui.right.internal.rail{left:auto;right:0;padding:0 2rem 0 0;margin:0 2rem 0 0}.ui.dividing.rail{width:302.5px}.ui.left.dividing.rail{padding:0 2.5rem 0 0;margin:0 2.5rem 0 0;border-right:1px solid rgba(34,36,38,.15)}.ui.right.dividing.rail{border-left:1px solid rgba(34,36,38,.15);padding:0 0 0 2.5rem;margin:0 0 0 2.5rem}.ui.close.rail{width:301px}.ui.close.left.rail{padding:0 1em 0 0;margin:0 1em 0 0}.ui.close.right.rail{padding:0 0 0 1em;margin:0 0 0 1em}.ui.very.close.rail{width:300.5px}.ui.very.close.left.rail{padding:0 .5em 0 0;margin:0 .5em 0 0}.ui.very.close.right.rail{padding:0 0 0 .5em;margin:0 0 0 .5em}.ui.attached.left.rail,.ui.attached.right.rail{padding:0;margin:0} -------------------------------------------------------------------------------- /backend/public/semantic-ui/components/reset.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * # Semantic UI 2.0.0 - Reset 3 | * http://github.com/semantic-org/semantic-ui/ 4 | * 5 | * 6 | * Copyright 2015 Contributors 7 | * Released under the MIT license 8 | * http://opensource.org/licenses/MIT 9 | * 10 | */*,:after,:before{box-sizing:inherit}html{box-sizing:border-box;font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}input[type=text],input[type=email],input[type=search],input[type=password]{-webkit-appearance:none;-moz-appearance:none}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background:0 0}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,optgroup,strong{font-weight:700}dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}pre,textarea{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-appearance:textfield;box-sizing:content-box}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{border:0;padding:0}table{border-collapse:collapse;border-spacing:0}td,th{padding:0} -------------------------------------------------------------------------------- /backend/public/semantic-ui/components/shape.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * # Semantic UI 2.0.0 - Shape 3 | * http://github.com/semantic-org/semantic-ui/ 4 | * 5 | * 6 | * Copyright 2015 Contributors 7 | * Released under the MIT license 8 | * http://opensource.org/licenses/MIT 9 | * 10 | */.ui.shape{position:relative;vertical-align:top;display:inline-block;-webkit-perspective:2000px;perspective:2000px;-webkit-transition:-webkit-transform .6s ease-in-out,left .6s ease-in-out,width .6s ease-in-out,height .6s ease-in-out;transition:transform .6s ease-in-out,left .6s ease-in-out,width .6s ease-in-out,height .6s ease-in-out}.ui.shape .sides{-webkit-transform-style:preserve-3d;transform-style:preserve-3d}.ui.shape .side{opacity:1;width:100%;margin:0!important;-webkit-backface-visibility:hidden;backface-visibility:hidden;display:none}.ui.shape .side *{-webkit-backface-visibility:visible!important;backface-visibility:visible!important}.ui.cube.shape .side{min-width:15em;height:15em;padding:2em;background-color:#e6e6e6;color:rgba(0,0,0,.87);box-shadow:0 0 2px rgba(0,0,0,.3)}.ui.cube.shape .side>.content{width:100%;height:100%;display:table;text-align:center;-webkit-user-select:text;-moz-user-select:text;-ms-user-select:text;user-select:text}.ui.cube.shape .side>.content>div{display:table-cell;vertical-align:middle;font-size:2em}.ui.text.shape.animating .sides{position:static}.ui.text.shape .side{white-space:nowrap}.ui.text.shape .side>*{white-space:normal}.ui.loading.shape{position:absolute;top:-9999px;left:-9999px}.ui.shape .animating.side{position:absolute;top:0;left:0;display:block;z-index:100}.ui.shape .hidden.side{opacity:.6}.ui.shape.animating .sides{position:absolute;-webkit-transition:-webkit-transform .6s ease-in-out,left .6s ease-in-out,width .6s ease-in-out,height .6s ease-in-out;transition:transform .6s ease-in-out,left .6s ease-in-out,width .6s ease-in-out,height .6s ease-in-out}.ui.shape.animating .side{-webkit-transition:opacity .6s ease-in-out;transition:opacity .6s ease-in-out}.ui.shape .active.side{display:block} -------------------------------------------------------------------------------- /backend/public/semantic-ui/components/site.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * # Semantic UI 2.0.0 - Site 3 | * http://github.com/semantic-org/semantic-ui/ 4 | * 5 | * 6 | * Copyright 2015 Contributors 7 | * Released under the MIT license 8 | * http://opensource.org/licenses/MIT 9 | * 10 | */@import url(https://fonts.googleapis.com/css?family=Lato:400,700,400italic,700italic&subset=latin);body,html{height:100%}html{font-size:14px}body{margin:0;padding:0;overflow-x:hidden;min-width:320px;background:#fff;font-family:Lato,'Helvetica Neue',Arial,Helvetica,sans-serif;font-size:14px;line-height:1.4285em;color:rgba(0,0,0,.87);font-smoothing:antialiased}h1,h2,h3,h4,h5{font-family:Lato,'Helvetica Neue',Arial,Helvetica,sans-serif;line-height:1.2857em;margin:calc(2rem - .14285em) 0 1rem;font-weight:700;padding:0}h1{min-height:1rem;font-size:2rem}h2{font-size:1.714rem}h3{font-size:1.28rem}h4{font-size:1.071rem}h5{font-size:1rem}h1:first-child,h2:first-child,h3:first-child,h4:first-child,h5:first-child,p:first-child{margin-top:0}h1:last-child,h2:last-child,h3:last-child,h4:last-child,h5:last-child,p:last-child{margin-bottom:0}p{margin:0 0 1em;line-height:1.4285em}a{color:#4183c4;text-decoration:none}a:hover{color:#1e70bf;text-decoration:none}::-webkit-selection{background-color:#cce2ff;color:rgba(0,0,0,.87)}::-moz-selection{background-color:#cce2ff;color:rgba(0,0,0,.87)}::selection{background-color:#cce2ff;color:rgba(0,0,0,.87)}input::-webkit-selection,textarea::-webkit-selection{background-color:rgba(100,100,100,.4);color:rgba(0,0,0,.87)}input::-moz-selection,textarea::-moz-selection{background-color:rgba(100,100,100,.4);color:rgba(0,0,0,.87)}input::selection,textarea::selection{background-color:rgba(100,100,100,.4);color:rgba(0,0,0,.87)} -------------------------------------------------------------------------------- /backend/public/semantic-ui/components/sticky.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * # Semantic UI 2.0.0 - Sticky 3 | * http://github.com/semantic-org/semantic-ui/ 4 | * 5 | * 6 | * Copyright 2015 Contributors 7 | * Released under the MIT license 8 | * http://opensource.org/licenses/MIT 9 | * 10 | */ 11 | 12 | 13 | /******************************* 14 | Sticky 15 | *******************************/ 16 | 17 | .ui.sticky { 18 | position: static; 19 | -webkit-transition: none; 20 | transition: none; 21 | z-index: 800; 22 | } 23 | 24 | 25 | /******************************* 26 | States 27 | *******************************/ 28 | 29 | 30 | /* Bound */ 31 | .ui.sticky.bound { 32 | position: absolute; 33 | left: auto; 34 | right: auto; 35 | } 36 | 37 | /* Fixed */ 38 | .ui.sticky.fixed { 39 | position: fixed; 40 | left: auto; 41 | right: auto; 42 | } 43 | 44 | /* Bound/Fixed Position */ 45 | .ui.sticky.bound.top, 46 | .ui.sticky.fixed.top { 47 | top: 0px; 48 | bottom: auto; 49 | } 50 | .ui.sticky.bound.bottom, 51 | .ui.sticky.fixed.bottom { 52 | top: auto; 53 | bottom: 0px; 54 | } 55 | 56 | 57 | /******************************* 58 | Types 59 | *******************************/ 60 | 61 | .ui.native.sticky { 62 | position: -webkit-sticky; 63 | position: -moz-sticky; 64 | position: -ms-sticky; 65 | position: -o-sticky; 66 | position: sticky; 67 | } 68 | 69 | 70 | /******************************* 71 | Theme Overrides 72 | *******************************/ 73 | 74 | 75 | 76 | /******************************* 77 | Site Overrides 78 | *******************************/ 79 | 80 | -------------------------------------------------------------------------------- /backend/public/semantic-ui/components/sticky.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * # Semantic UI 2.0.0 - Sticky 3 | * http://github.com/semantic-org/semantic-ui/ 4 | * 5 | * 6 | * Copyright 2015 Contributors 7 | * Released under the MIT license 8 | * http://opensource.org/licenses/MIT 9 | * 10 | */.ui.sticky{position:static;-webkit-transition:none;transition:none;z-index:800}.ui.sticky.bound{position:absolute;left:auto;right:auto}.ui.sticky.fixed{position:fixed;left:auto;right:auto}.ui.sticky.bound.top,.ui.sticky.fixed.top{top:0;bottom:auto}.ui.sticky.bound.bottom,.ui.sticky.fixed.bottom{top:auto;bottom:0}.ui.native.sticky{position:-webkit-sticky;position:-moz-sticky;position:-ms-sticky;position:-o-sticky;position:sticky} -------------------------------------------------------------------------------- /backend/public/semantic-ui/components/tab.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * # Semantic UI 2.0.0 - Tab 3 | * http://github.com/semantic-org/semantic-ui/ 4 | * 5 | * 6 | * Copyright 2015 Contributors 7 | * Released under the MIT license 8 | * http://opensource.org/licenses/MIT 9 | * 10 | */ 11 | 12 | 13 | /******************************* 14 | UI Tabs 15 | *******************************/ 16 | 17 | .ui.tab { 18 | display: none; 19 | } 20 | 21 | 22 | /******************************* 23 | States 24 | *******************************/ 25 | 26 | 27 | /*-------------------- 28 | Active 29 | ---------------------*/ 30 | 31 | .ui.tab.active, 32 | .ui.tab.open { 33 | display: block; 34 | } 35 | 36 | /*-------------------- 37 | Loading 38 | ---------------------*/ 39 | 40 | .ui.tab.loading { 41 | position: relative; 42 | overflow: hidden; 43 | display: block; 44 | min-height: 250px; 45 | } 46 | .ui.tab.loading * { 47 | position: relative !important; 48 | left: -10000px !important; 49 | } 50 | .ui.tab.loading:before, 51 | .ui.tab.loading.segment:before { 52 | position: absolute; 53 | content: ''; 54 | top: 100px; 55 | left: 50%; 56 | margin: -1.25em 0em 0em -1.25em; 57 | width: 2.5em; 58 | height: 2.5em; 59 | border-radius: 500rem; 60 | border: 0.2em solid rgba(0, 0, 0, 0.1); 61 | } 62 | .ui.tab.loading:after, 63 | .ui.tab.loading.segment:after { 64 | position: absolute; 65 | content: ''; 66 | top: 100px; 67 | left: 50%; 68 | margin: -1.25em 0em 0em -1.25em; 69 | width: 2.5em; 70 | height: 2.5em; 71 | -webkit-animation: button-spin 0.6s linear; 72 | animation: button-spin 0.6s linear; 73 | -webkit-animation-iteration-count: infinite; 74 | animation-iteration-count: infinite; 75 | border-radius: 500rem; 76 | border-color: #767676 transparent transparent; 77 | border-style: solid; 78 | border-width: 0.2em; 79 | box-shadow: 0px 0px 0px 1px transparent; 80 | } 81 | 82 | 83 | /******************************* 84 | Tab Overrides 85 | *******************************/ 86 | 87 | 88 | 89 | /******************************* 90 | User Overrides 91 | *******************************/ 92 | 93 | -------------------------------------------------------------------------------- /backend/public/semantic-ui/components/tab.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * # Semantic UI 2.0.0 - Tab 3 | * http://github.com/semantic-org/semantic-ui/ 4 | * 5 | * 6 | * Copyright 2015 Contributors 7 | * Released under the MIT license 8 | * http://opensource.org/licenses/MIT 9 | * 10 | */.ui.tab{display:none}.ui.tab.active,.ui.tab.open{display:block}.ui.tab.loading{position:relative;overflow:hidden;display:block;min-height:250px}.ui.tab.loading *{position:relative!important;left:-10000px!important}.ui.tab.loading.segment:before,.ui.tab.loading:before{position:absolute;content:'';top:100px;left:50%;margin:-1.25em 0 0 -1.25em;width:2.5em;height:2.5em;border-radius:500rem;border:.2em solid rgba(0,0,0,.1)}.ui.tab.loading.segment:after,.ui.tab.loading:after{position:absolute;content:'';top:100px;left:50%;margin:-1.25em 0 0 -1.25em;width:2.5em;height:2.5em;-webkit-animation:button-spin .6s linear;animation:button-spin .6s linear;-webkit-animation-iteration-count:infinite;animation-iteration-count:infinite;border-radius:500rem;border-color:#767676 transparent transparent;border-style:solid;border-width:.2em;box-shadow:0 0 0 1px transparent} -------------------------------------------------------------------------------- /backend/public/semantic-ui/components/video.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * # Semantic UI 2.0.0 - Video 3 | * http://github.com/semantic-org/semantic-ui/ 4 | * 5 | * 6 | * Copyright 2014 Contributors 7 | * Released under the MIT license 8 | * http://opensource.org/licenses/MIT 9 | * 10 | */.ui.video{background-color:#ddd;position:relative;max-width:100%;padding-bottom:56.25%;height:0;overflow:hidden}.ui.video .placeholder{background-color:#333}.ui.video .play{cursor:pointer;position:absolute;top:0;left:0;z-index:10;width:100%;height:100%;background:0 0;-webkit-transition:background .2s ease;transition:background .2s ease}.ui.video .play.icon:before{position:absolute;top:50%;left:50%;z-index:11;-webkit-transform:translateX(-50%)translateY(-50%);-ms-transform:translateX(-50%)translateY(-50%);transform:translateX(-50%)translateY(-50%);color:rgba(255,255,255,.7);font-size:7rem;text-shadow:2px 2px 0 rgba(0,0,0,.15);-webkit-transition:color .2s ease;transition:color .2s ease}.ui.video .placeholder{position:absolute;top:0;left:0;display:block;width:100%;height:100%}.ui.video .embed embed,.ui.video .embed iframe,.ui.video .embed object{position:absolute;border:none;width:100%;height:100%;top:0;left:0;margin:0;padding:0}.ui.video .play:hover{background:0 0}.ui.video .play:hover:before{color:#fff}.ui.active.video .placeholder,.ui.active.video .play{display:none}.ui.active.video .embed{display:inline} -------------------------------------------------------------------------------- /backend/public/skin/blue.monday/image/jplayer.blue.monday.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/backend/public/skin/blue.monday/image/jplayer.blue.monday.jpg -------------------------------------------------------------------------------- /backend/public/skin/blue.monday/image/jplayer.blue.monday.seeking.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/backend/public/skin/blue.monday/image/jplayer.blue.monday.seeking.gif -------------------------------------------------------------------------------- /backend/public/skin/blue.monday/image/jplayer.blue.monday.video.play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/backend/public/skin/blue.monday/image/jplayer.blue.monday.video.play.png -------------------------------------------------------------------------------- /backend/public/skin/blue.monday/mustache/jplayer.blue.monday.audio.playlist.html: -------------------------------------------------------------------------------- 1 | 2 | 43 | -------------------------------------------------------------------------------- /backend/public/skin/blue.monday/mustache/jplayer.blue.monday.audio.single.html: -------------------------------------------------------------------------------- 1 | 2 | 38 | -------------------------------------------------------------------------------- /backend/public/skin/blue.monday/mustache/jplayer.blue.monday.audio.stream.html: -------------------------------------------------------------------------------- 1 | 2 | 25 | -------------------------------------------------------------------------------- /backend/public/skin/blue.monday/mustache/jplayer.blue.monday.video.playlist.html: -------------------------------------------------------------------------------- 1 | 53 | -------------------------------------------------------------------------------- /backend/public/skin/blue.monday/mustache/jplayer.blue.monday.video.single.html: -------------------------------------------------------------------------------- 1 | 44 | -------------------------------------------------------------------------------- /backend/public/skin/pink.flag/image/jplayer.pink.flag.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/backend/public/skin/pink.flag/image/jplayer.pink.flag.jpg -------------------------------------------------------------------------------- /backend/public/skin/pink.flag/image/jplayer.pink.flag.seeking.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/backend/public/skin/pink.flag/image/jplayer.pink.flag.seeking.gif -------------------------------------------------------------------------------- /backend/public/skin/pink.flag/image/jplayer.pink.flag.video.play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/backend/public/skin/pink.flag/image/jplayer.pink.flag.video.play.png -------------------------------------------------------------------------------- /backend/public/skin/pink.flag/mustache/jplayer.pink.flag.audio.playlist.html: -------------------------------------------------------------------------------- 1 | 2 | 43 | -------------------------------------------------------------------------------- /backend/public/skin/pink.flag/mustache/jplayer.pink.flag.audio.single.html: -------------------------------------------------------------------------------- 1 | 2 | 38 | -------------------------------------------------------------------------------- /backend/public/skin/pink.flag/mustache/jplayer.pink.flag.audio.stream.html: -------------------------------------------------------------------------------- 1 | 2 | 25 | -------------------------------------------------------------------------------- /backend/public/skin/pink.flag/mustache/jplayer.pink.flag.video.playlist.html: -------------------------------------------------------------------------------- 1 | 53 | -------------------------------------------------------------------------------- /backend/public/skin/pink.flag/mustache/jplayer.pink.flag.video.single.html: -------------------------------------------------------------------------------- 1 | 44 | -------------------------------------------------------------------------------- /backend/test/spec.js: -------------------------------------------------------------------------------- 1 | var request = require('supertest'); 2 | 3 | describe('loading express', function () { 4 | var server; 5 | beforeEach(function () { 6 | server = require('../index'); 7 | }); 8 | afterEach(function () { 9 | server.close(); 10 | }); 11 | /* it('responds to /call/576213019f9c3d0100539da8', function testSlash(done) { 12 | request(server) 13 | .get('/call/576213019f9c3d0100539da8') 14 | .expect(200) 15 | .end(function (err, res) { 16 | if (err) return done(err); 17 | done(); 18 | }); 19 | });*/ 20 | it('responds to /channels', function testSlash(done) { 21 | 22 | request(server) 23 | .get('/channels') 24 | .expect(200) 25 | .end(function (err, res) { 26 | if (err) return done(err); 27 | done(); 28 | }); 29 | }); 30 | /* it('404 everything else', function testPath(done) { 31 | request(server) 32 | .get('/foo/bar') 33 | .expect(404) 34 | .end(function (err, res) { 35 | if (err) return done(err); 36 | done(); 37 | }); 38 | });*/ 39 | }); 40 | -------------------------------------------------------------------------------- /certbot-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | nginx: 4 | build: 5 | context: ./nginx-proxy 6 | args: 7 | STAGE: "cert" 8 | environment: 9 | - DOMAIN_NAME 10 | image: "nginx:${TAG}-cert" 11 | networks: 12 | - proxy 13 | ports: 14 | - "80:80" 15 | restart: always 16 | volumes: 17 | - ${PWD}/data/log/syslog:/var/log/syslog 18 | - ${PWD}/data/log/nginx:/var/log/nginx 19 | - ${PWD}/data/certbot/www/:/var/www/certbot/:ro 20 | - ${PWD}/data/certbot/conf/:/etc/letsencrypt/:ro 21 | command: /bin/bash -c "envsubst '$$DOMAIN_NAME' < /etc/nginx/conf.d/site.template > /etc/nginx/conf.d/default.conf && while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g 'daemon off;'" 22 | 23 | certbot: 24 | image: certbot/certbot:latest 25 | volumes: 26 | - ${PWD}/data/certbot/www/:/var/www/certbot/:rw 27 | - ${PWD}/data/certbot/conf/:/etc/letsencrypt/:rw 28 | entrypoint: "certbot certonly --webroot -w /var/www/certbot \ 29 | --email ${REACT_APP_ADMIN_EMAIL} \ 30 | -d ${DOMAIN_NAME} -d www.${DOMAIN_NAME} -d api.${DOMAIN_NAME} -d admin.${DOMAIN_NAME} -d account.${DOMAIN_NAME} \ 31 | --rsa-key-size 4096 \ 32 | --agree-tos \ 33 | --force-renewal" 34 | 35 | networks: 36 | proxy: 37 | node: 38 | -------------------------------------------------------------------------------- /data/log/nginx/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /data/log/syslog/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /data/media/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /data/upload/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /docker-prod.sh: -------------------------------------------------------------------------------- 1 | source prod.env 2 | echo "Docker Compose Command: " $@ 3 | docker compose -f docker-compose.yml $@ 4 | -------------------------------------------------------------------------------- /docker-test.sh: -------------------------------------------------------------------------------- 1 | source test.env 2 | echo "Domains: " $DOMAIN_NAME 3 | echo "Protocol: " $PROTOCOL 4 | echo "Docker Compose Command: " $@ 5 | docker compose -f docker-compose.yml -f test-compose.yml $@ 6 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /build -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /frontend/.not-env: -------------------------------------------------------------------------------- 1 | REACT_APP_BACKEND_SERVER=https://api.openmhz.com 2 | REACT_APP_FRONTEND_SERVER=https://openmhz.com 3 | REACT_APP_SOCKET_SERVER=wss://api.openmhz.com 4 | REACT_APP_SITE_NAME=OpenMHz 5 | REACT_APP_ARCHIVE_DAYS=30 6 | REACT_APP_PRO_PLAN=1 7 | REACT_APP_FREE_PLAN=0 8 | REACT_APP_PRO_PLAN_ARCHIVE=30 9 | REACT_APP_FREE_PLAN_ARCHIVE=7 10 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # build environment 2 | FROM node:19-alpine3.16 as build 3 | WORKDIR /app 4 | ENV PATH /app/node_modules/.bin:$PATH 5 | ARG REACT_APP_BACKEND_SERVER 6 | ARG REACT_APP_SITE_NAME 7 | ARG REACT_APP_FRONTEND_SERVER 8 | ARG REACT_APP_GOOGLE_ANALYTICS 9 | ARG NODE_ENV 10 | COPY package.json ./ 11 | RUN npm install --include=dev 12 | COPY . ./ 13 | RUN npm run build 14 | 15 | # production environment 16 | FROM node:19-alpine3.16 17 | RUN mkdir -p /app/public 18 | COPY ./package.json /tmp 19 | RUN cd /tmp && npm install 20 | RUN cp -a /tmp/node_modules /app 21 | RUN npm install -g nodemon 22 | WORKDIR /app 23 | COPY server /app 24 | COPY --from=build /app/build /app/public 25 | RUN sed 's//<%- TWITTER_META%>/g' /app/public/index.html > /app/index.ejs 26 | CMD ["node", "index.js"] -------------------------------------------------------------------------------- /frontend/env-example: -------------------------------------------------------------------------------- 1 | REACT_APP_BACKEND_SERVER=https://api.openmhz.com 2 | REACT_APP_FRONTEND_SERVER=https://openmhz.com 3 | REACT_APP_SITE_NAME=OpenMHz 4 | REACT_APP_ARCHIVE_DAYS=30 5 | 6 | -------------------------------------------------------------------------------- /frontend/notes.md: -------------------------------------------------------------------------------- 1 | ## Elements Used 2 | - Create React App 3 | - Redux 4 | - [Redux Thunk](https://github.com/reduxjs/redux-thunk): lets you do AJAX calls 5 | - [connected-react-router](https://github.com/supasate/connected-react-router): Synchronize router state with redux store through uni-directional flow (i.e. history -> store -> router -> components). Basically, if you go back the redux store will update... I think 6 | - [axios](https://github.com/axios/axios) - library for doing HTTP requests 7 | - [@artsy/fresnel](https://github.com/artsy/fresnel) - Handles media requests for responsive designs and works server side too. https://github.com/Semantic-Org/Semantic-UI-React/pull/4008. Used for the Main page to make it responsive. 8 | - [React Date Picker](https://github.com/Hacker0x01/react-datepicker) - the popup calendar that lets you pick a date. Used in the calendar Modal. 9 | - [React Audio Player](https://github.com/justinmc/react-audio-player) - This is a light React wrapper around the HTML5 audio tag. It provides the ability to manipulate the player and listen to events through a nice React interface 10 | - [date-fns](https://date-fns.org) - date-fns provides the most comprehensive, yet simple and consistent toolset for manipulating JavaScript dates in a browser & Node.js. Used in the calendar modal to figure out how far apart days are. 11 | - [Semantic UI](https://react.semantic-ui.com/usage) - The UI frontend 12 | - [react-router-dom](https://reactrouter.com/web/guides/quick-start) - Adds routes to React 13 | ## Environment Variables 14 | CRA lets you sub in env variables into both HTML and JS: https://create-react-app.dev/docs/adding-custom-environment-variables/ -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@artsy/fresnel": "6.1.0", 7 | "@lagunovsky/redux-react-router": "^4.3.0", 8 | "@reduxjs/toolkit": "^1.9.1", 9 | "@testing-library/jest-dom": "^5.16.5", 10 | "@testing-library/react": "^13.4.0", 11 | "@testing-library/user-event": "^14.4.3", 12 | "@visx/axis": "^3.1.0", 13 | "@visx/curve": "^3.0.0", 14 | "@visx/event": "^3.0.1", 15 | "@visx/gradient": "^3.0.0", 16 | "@visx/grid": "^3.0.1", 17 | "@visx/group": "^3.0.0", 18 | "@visx/mock-data": "^3.0.0", 19 | "@visx/scale": "^3.0.0", 20 | "@visx/shape": "^3.0.0", 21 | "@visx/tooltip": "^3.1.2", 22 | "@wavesurfer/react": "^1.0.7", 23 | "axios": "1.2.1", 24 | "d3-array": "^3.2.3", 25 | "d3-time-format": "^4.1.0", 26 | "date-fns": "2.29.3", 27 | "ejs": "3.1.8", 28 | "history": "^5.3.0", 29 | "mongodb": "^4.13.0", 30 | "podcast": "^2.0.1", 31 | "react": "^18.2.0", 32 | "react-audio-player": "0.17.0", 33 | "react-datepicker": "4.8.0", 34 | "react-dom": "^18.2.0", 35 | "react-ga": "^3.3.1", 36 | "react-intersection-observer": "^9.4.1", 37 | "react-redux": "^8.0.5", 38 | "react-router-dom": "6.6.1", 39 | "react-scripts": "5.0.1", 40 | "redux-thunk": "2.4.2", 41 | "semantic-ui-css": "2.5.0", 42 | "semantic-ui-react": "2.1.4", 43 | "serve-favicon": "^2.5.0", 44 | "socket.io-client": "^4.5.4" 45 | }, 46 | "scripts": { 47 | "start": "react-scripts start", 48 | "build": "react-scripts build", 49 | "test": "react-scripts test", 50 | "eject": "react-scripts eject" 51 | }, 52 | "eslintConfig": { 53 | "extends": "react-app" 54 | }, 55 | "browserslist": { 56 | "production": [ 57 | ">0.2%", 58 | "not dead", 59 | "not op_mini all" 60 | ], 61 | "development": [ 62 | "last 1 chrome version", 63 | "last 1 firefox version", 64 | "last 1 safari version" 65 | ] 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /frontend/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/frontend/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /frontend/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/frontend/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /frontend/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/frontend/public/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/frontend/public/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/frontend/public/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/podcast/channel_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/frontend/public/podcast/channel_icon.png -------------------------------------------------------------------------------- /frontend/public/podcast/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/frontend/public/podcast/cover.png -------------------------------------------------------------------------------- /frontend/public/radio-400x400.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/frontend/public/radio-400x400.jpg -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /frontend/public/silence.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/frontend/public/silence.m4a -------------------------------------------------------------------------------- /frontend/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /frontend/server/card.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | OpenMHz 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 | 14 |
15 | 43 | 44 |
45 | 46 | 47 | -------------------------------------------------------------------------------- /frontend/server/config/secrets.js: -------------------------------------------------------------------------------- 1 | /** Important **/ 2 | /** You should not be committing this file to GitHub **/ 3 | /** Repeat: DO! NOT! COMMIT! THIS! FILE! TO! YOUR! REPO! **/ 4 | var host = process.env['MONGO_NODE_DRIVER_HOST'] != null ? process.env['MONGO_NODE_DRIVER_HOST'] : 'localhost'; 5 | var port = process.env['MONGO_NODE_DRIVER_PORT'] != null ? process.env['MONGO_NODE_DRIVER_PORT'] : 27017; 6 | var mongoUrl = 'mongodb://' + host + ':' + port + '/scanner'; 7 | 8 | 9 | 10 | const secrets = { 11 | db: mongoUrl, 12 | sessionSecret: "letthisbeyoursecret" 13 | } 14 | 15 | module.exports = secrets 16 | -------------------------------------------------------------------------------- /frontend/server/db.js: -------------------------------------------------------------------------------- 1 | var MongoClient = require('mongodb').MongoClient; 2 | 3 | 4 | var host = process.env['MONGO_NODE_DRIVER_HOST'] != null ? process.env['MONGO_NODE_DRIVER_HOST'] : 'localhost'; 5 | var port = process.env['MONGO_NODE_DRIVER_PORT'] != null ? process.env['MONGO_NODE_DRIVER_PORT'] : 27017; 6 | var dbUser = process.env['MONGO_TRUNK_FRONTEND_USER'] != null ? process.env['MONGO_TRUNK_FRONTEND_USER'] : null; 7 | var dbPass = process.env['MONGO_TRUNK_FRONTEND_PASS'] != null ? process.env['MONGO_TRUNK_FRONTEND_PASS'] : null; 8 | 9 | var state = { 10 | db: null, 11 | } 12 | 13 | exports.connect = function(done) { 14 | if (state.db) return done() 15 | 16 | var url = 'mongodb://' + host + ':' + port; 17 | 18 | MongoClient.connect(url, function(err, client) { 19 | if (err) return done(err) 20 | 21 | //test.equal(true, result); 22 | state.db = client.db('scanner'); 23 | done(); 24 | }) 25 | } 26 | 27 | exports.get = function() { 28 | return state.db 29 | } 30 | 31 | exports.close = function(done) { 32 | if (state.db) { 33 | state.db.close(function(err, result) { 34 | state.db = null 35 | state.mode = null 36 | done(err) 37 | }) 38 | } 39 | } -------------------------------------------------------------------------------- /frontend/server/env.ejs: -------------------------------------------------------------------------------- 1 | const REACT_APP_ADMIN_EMAIL=<%- REACT_APP_ADMIN_EMAIL> 2 | const REACT_APP_SITE_NAME=<%- REACT_APP_SITE_NAME> 3 | const REACT_APP_BACKEND_SERVER=<%- REACT_APP_BACKEND_SERVER> 4 | const REACT_APP_FRONTEND_SERVER=<%- REACT_APP_FRONTEND_SERVER> 5 | -------------------------------------------------------------------------------- /frontend/src/Call/Activity.js: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { useParams } from 'react-router-dom'; 3 | 4 | 5 | import { useGetStatsQuery,useGetTalkgroupsQuery } from '../features/api/apiSlice' 6 | import ActivityChart from "./BetterActivityChart"; 7 | import { 8 | Container, 9 | Header 10 | 11 | } from "semantic-ui-react"; 12 | import "./CallPlayer.css"; 13 | 14 | 15 | 16 | function Activity(props) { 17 | const { shortName } = useParams(); 18 | const { data: statsData, isSuccess: isStatsSuccess } = useGetStatsQuery(shortName); 19 | const { data: talkgroupData, isSuccess: isTalkgroupsSuccess } = useGetTalkgroupsQuery(shortName); 20 | 21 | const talkgroupStats = useMemo(() => { 22 | if (statsData ) { 23 | const result =[] 24 | for (const tgNum in statsData.talkgroupStats) { 25 | let tg = tgNum; 26 | if (talkgroupData && talkgroupData.talkgroups[tgNum] !== undefined) { 27 | tg = talkgroupData.talkgroups[tgNum].description; 28 | } 29 | result.push(); 30 | } 31 | return result; 32 | } else { 33 | return []; 34 | } 35 | 36 | 37 | }, [statsData, talkgroupData]); 38 | 39 | 40 | return ( 41 | 42 |
Talkgroup Activity
43 | { 44 | talkgroupStats.map( chart => { 45 | return chart 46 | }) 47 | } 48 | 49 |
50 | ) 51 | 52 | } 53 | 54 | export default Activity; -------------------------------------------------------------------------------- /frontend/src/Call/CallPlayer.css: -------------------------------------------------------------------------------- 1 | 2 | /******************************* 3 | Responsive 4 | *******************************/ 5 | /* Defaults */ 6 | 7 | .ui.progress:first-child { 8 | width: 150px; 9 | margin: 0px; 10 | } 11 | /* 12 | .ui.menu .item { 13 | padding-top: 0px; 14 | padding-bottom: 0px; 15 | }*/ 16 | 17 | /* Needed to fix an error with Semantic UI react 18 | https://github.com/Semantic-Org/Semantic-UI-React/issues/2558 19 | https://github.com/Semantic-Org/Semantic-UI-React/issues/2550 20 | */ 21 | 22 | .ui.modal { 23 | margin-top: 0 !important; 24 | position: unset; 25 | } 26 | 27 | .main.container { 28 | 29 | width: auto !important; 30 | max-width: 960px !important; 31 | margin-top: 55px; 32 | } 33 | 34 | 35 | @media only screen and (min-width: 1024px) { 36 | 37 | .main.container { 38 | margin-left: 3em !important; 39 | margin-right: 387px !important; 40 | } 41 | 42 | /* Rail Default */ 43 | .main.container > .right.rail 44 | { 45 | margin-left: 3em; 46 | padding-top: 2em; 47 | width: 319px; 48 | right: 20px; 49 | left: auto; 50 | } 51 | 52 | } 53 | @media only screen and (min-width: 768px) { 54 | /* .desktop-only { 55 | display:block !important; 56 | }*/ 57 | 58 | .mobile-only { 59 | display:none !important; 60 | } 61 | 62 | } 63 | 64 | @media only screen and (min-width:768px) and (max-width: 1023px) { 65 | 66 | 67 | .main.container { 68 | margin-right: 250px !important; 69 | } 70 | 71 | /* Rail Default */ 72 | .main.container > .right.rail 73 | { 74 | margin-left: 3em; 75 | width: 250px; 76 | } 77 | 78 | #calls { 79 | margin-left: 25px; 80 | margin-right: 25px; 81 | } 82 | } 83 | 84 | 85 | @media only screen and (max-width: 767px) { 86 | 87 | .mobile-only { 88 | display:block !important; 89 | } 90 | #call-header { 91 | display:none !important; 92 | } 93 | .main.container 94 | { 95 | margin-left: 0px !important; 96 | margin-right: 0px !important; 97 | } 98 | /* 99 | .ui.table:not(.unstackable) tr > td.desktop-only { 100 | display:none !important; 101 | } */ 102 | .desktop-only { 103 | display:none !important; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /frontend/src/Call/components/CalendarModal.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | .react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list { 4 | padding-left: 0; 5 | padding-right: 0; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/Call/components/CalendarModal.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { 3 | Modal, 4 | Button, 5 | Icon 6 | } from "semantic-ui-react"; 7 | import "./CalendarModal.css"; 8 | import { setDateFilter } from "../../features/callPlayer/callPlayerSlice"; 9 | import DatePicker from 'react-datepicker'; 10 | import { useDispatch } from 'react-redux' 11 | import { subDays } from 'date-fns' 12 | import 'react-datepicker/dist/react-datepicker.css'; 13 | 14 | function CalendarModal(props) { 15 | const [startDate, setStartDate] = useState(new Date()); 16 | const dispatch = useDispatch() 17 | const onClose = props.onClose; 18 | 19 | const handleDateChange = (date) => { 20 | setStartDate(date); 21 | } 22 | 23 | const handleClose = () => this.props.onClose(false); 24 | const handleDone = (onClose) => { 25 | dispatch(setDateFilter(startDate.getTime())); 26 | onClose(true); 27 | } 28 | 29 | 30 | 31 | 32 | return ( 33 | 34 | 35 | Select a Date & Time 36 | 37 | 38 | 49 | 50 | 51 | 52 | 55 | 56 | 57 | 58 | ) 59 | } 60 | 61 | 62 | export default CalendarModal; 63 | -------------------------------------------------------------------------------- /frontend/src/Call/components/CallLinks.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | 4 | export function useCallLink(currentCall) { 5 | const filterType = useSelector((state) => state.callPlayer.filterType); 6 | const filterGroupId = useSelector((state) => state.callPlayer.filterGroupId); 7 | const filterTalkgroups = useSelector((state) => state.callPlayer.filterTalkgroups); 8 | const shortName = useSelector((state) => state.callPlayer.shortName); 9 | if (currentCall) { 10 | let search = "" 11 | const time = new Date(currentCall.time); 12 | const callTime = time.toLocaleTimeString(); 13 | const callDate = time.toLocaleDateString(); 14 | 15 | switch (filterType) { 16 | 17 | case 1: 18 | search = `filter-type=group&filter-code=${filterGroupId}&`; 19 | break; 20 | case 2: 21 | search = `filter-type=talkgroup&filter-code=${filterTalkgroups}&`; 22 | break; 23 | default: 24 | case 0: 25 | break; 26 | 27 | } 28 | const callLink = "/system/" + shortName + "?" + search + "call-id=" + currentCall._id + "&time=" + (time.getTime() + 1); 29 | const callDownload = currentCall.url; 30 | const callTweet = "https://twitter.com/intent/tweet?url=" + encodeURIComponent(document.location.origin + callLink) + "&via=" + encodeURIComponent("OpenMHz"); 31 | 32 | return { callLink, callDownload, callTweet } 33 | } else { 34 | return { callLink: "", callDownload: "", callTweet: "" } 35 | } 36 | } -------------------------------------------------------------------------------- /frontend/src/Call/components/FilterModal.css: -------------------------------------------------------------------------------- 1 | .active.tab { 2 | min-height: 250px; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/Call/components/MediaPlayer.css: -------------------------------------------------------------------------------- 1 | .mediaplayer-container { 2 | /* We first create a flex layout context */ 3 | display: flex; 4 | 5 | 6 | 7 | padding: 0; 8 | margin: 0; 9 | list-style: none; 10 | } 11 | .item-container { 12 | /* We first create a flex layout context */ 13 | display: flex; 14 | 15 | 16 | 17 | padding: 0; 18 | margin: 0; 19 | list-style: none; 20 | } 21 | 22 | .label-item { 23 | padding: 9px 2px 5px 2px; 24 | width: 130px; 25 | color: white; 26 | font-weight: bold; 27 | text-align: center; 28 | } 29 | .mediaplayer { 30 | flex-grow: 1; 31 | color: white; 32 | font-weight: bold; 33 | text-align: center; 34 | } 35 | .mediaplayer-item { 36 | padding: 9px 5px 5px 5px; 37 | flex-grow: 1; 38 | color: white; 39 | font-weight: bold; 40 | text-align: center; 41 | } 42 | .link-item { 43 | display: flex; 44 | justify-content: center; 45 | align-items: center; 46 | padding: 5px; 47 | width: 100px; 48 | color: white; 49 | font-weight: bold; 50 | text-align: center; 51 | } 52 | .volume-icon { 53 | padding: 0px 15px 0px 15px; 54 | } 55 | .volume-slider { 56 | padding: 0px 0px 0px 0px; 57 | } 58 | .icon-button-item { 59 | cursor:pointer; 60 | display: flex; 61 | justify-content: center; 62 | align-items: center; 63 | padding: 5px; 64 | width: 50px; 65 | color: white; 66 | font-weight: bold; 67 | } 68 | 69 | .button-item { 70 | cursor:pointer; 71 | display: flex; 72 | justify-content: center; 73 | align-items: center; 74 | padding: 5px; 75 | width: 100px; 76 | color: white; 77 | font-weight: bold; 78 | } 79 | .active{ 80 | background-color: #5c5c5c; 81 | } 82 | 83 | .link-item a { 84 | 85 | color: white; 86 | text-align: center; 87 | } 88 | 89 | .button-item a { 90 | 91 | color: white; 92 | text-align: center; 93 | } 94 | 95 | @media only screen and (max-width: 767px) { 96 | 97 | .button-item { 98 | 99 | width: 40px; 100 | color: white; 101 | 102 | } 103 | .label-item { 104 | width: 50px; 105 | color: white; 106 | } 107 | } 108 | 109 | i.icon { 110 | font-size: inherit; 111 | } -------------------------------------------------------------------------------- /frontend/src/Call/components/PlaylistItem.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { 3 | Table, 4 | Icon, 5 | Label 6 | } from "semantic-ui-react"; 7 | 8 | 9 | 10 | const PlaylistItem = (props) => { 11 | const call = props.call; 12 | const tgAlpha = props.tgAlpha; 13 | const removeItem = props.removeItem; 14 | const index = props.index; 15 | const [removeVisible, setRemoveVisible] = useState(false); 16 | 17 | 18 | 19 | const handleRemoveClicked = (e) => { 20 | e.preventDefault(); 21 | e.stopPropagation(); 22 | e.nativeEvent.stopImmediatePropagation(); 23 | removeItem(call); 24 | } 25 | 26 | let removeButton; 27 | 28 | 29 | 30 | 31 | if (removeVisible) { 32 | removeButton = 33 | } else { 34 | removeButton = 35 | } 36 | 37 | 38 | 39 | const time = new Date(call.time); 40 | const callTime = time.toLocaleTimeString(); 41 | return ( 42 | 43 | {call.len} 44 | {tgAlpha} 45 | {callTime} 46 | setRemoveVisible(true)} onMouseLeave={() => setRemoveVisible(false)} onClick={handleRemoveClicked}>{removeButton} 47 | 48 | 49 | 50 | ); 51 | } 52 | 53 | export default PlaylistItem; 54 | -------------------------------------------------------------------------------- /frontend/src/Common/NavBar.js: -------------------------------------------------------------------------------- 1 | import { 2 | Container, 3 | Header, 4 | Card, 5 | Icon, 6 | Menu, 7 | Divider 8 | } from "semantic-ui-react"; 9 | import { Link, useNavigate } from 'react-router-dom' 10 | 11 | 12 | const NavBar = (props) => { 13 | 14 | 15 | const navigate = useNavigate(); 16 | return ( 17 | 18 | Home 19 | Systems 20 | Events 21 | About 22 | 23 | ); 24 | } 25 | 26 | 27 | export default NavBar; -------------------------------------------------------------------------------- /frontend/src/Common/SupportModal.js: -------------------------------------------------------------------------------- 1 | import {useState} from 'react'; 2 | import { 3 | Modal, 4 | Button, 5 | ButtonContent, 6 | ButtonGroup, 7 | Divider, 8 | Icon 9 | } from "semantic-ui-react"; 10 | 11 | 12 | function SupportModal(props) { 13 | 14 | const [open, setOpen] = useState(false); 15 | 16 | const handleClose = () => props.onClose(); 17 | 18 | return ( 19 | setOpen(false)} onOpen={() => setOpen(true)} trigger={props.trigger} size='tiny' > 20 | Support OpenMHz 21 | 22 | 23 | 24 |

If OpenMHz brings you joy, think about becoming a supporter! It will cover hosting costs and help keep me focused on development.

25 | 26 | Donate 27 | 28 | 29 | 30 | 31 |
32 | 33 |
34 | 35 | 38 | 39 |
40 | 41 | ) 42 | 43 | } 44 | 45 | export default SupportModal; 46 | -------------------------------------------------------------------------------- /frontend/src/Event/EventCallInfo.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Link, useLocation, useParams, useNavigate } from 'react-router-dom'; 3 | import { 4 | Header, 5 | Divider, 6 | List, 7 | Segment, 8 | Statistic, 9 | Icon, 10 | Menu, 11 | Tab 12 | } from "semantic-ui-react"; 13 | import CallInfoPane from "../Call/components/CallInfoPane" 14 | // ---------------------------------------------------- 15 | function EventCallInfo(props) { 16 | return ( 17 | 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | export default EventCallInfo; 25 | -------------------------------------------------------------------------------- /frontend/src/Event/EventCallItem.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { 3 | Table, 4 | Icon, 5 | Label 6 | } from "semantic-ui-react"; 7 | 8 | import { useGetTalkgroupsQuery } from '../features/api/apiSlice' 9 | 10 | 11 | const EventCallItem = (props) => { 12 | const call = props.call; 13 | const activeCall = props.activeCall; 14 | const { data: talkgroupsData, isSuccess: isTalkgroupsSuccess } = useGetTalkgroupsQuery(call.shortName); 15 | 16 | 17 | const time = new Date(call.time); 18 | 19 | 20 | 21 | let rowSelected = {}; 22 | 23 | 24 | 25 | 26 | 27 | 28 | if (activeCall) { 29 | rowSelected = { 30 | positive: true, 31 | color: "blue", 32 | key: "blue", 33 | inverted: "true" 34 | } 35 | } 36 | let talkgroup; 37 | if ((typeof talkgroupsData == 'undefined') || (typeof talkgroupsData.talkgroups[call.talkgroupNum] == 'undefined')) { 38 | talkgroup = call.talkgroupNum; 39 | } else { 40 | talkgroup = talkgroupsData.talkgroups[call.talkgroupNum].description; 41 | } 42 | 43 | const cirlceStyle = {width:"4px", 44 | margin:"6px", 45 | height: "4px", 46 | borderRadius:"50%"} 47 | 48 | 49 | let playStatus = (<>) 50 | if (activeCall) { 51 | playStatus = () 52 | 53 | } else { 54 | playStatus = (
) 55 | } 56 | 57 | return ( 58 | props.onClick({ call: call }, e)} {...rowSelected}> 59 | {playStatus} 60 | {call.len} 61 | {talkgroup} 62 | {time.toLocaleTimeString()} 63 | {call.shortName} 64 | 65 | ); 66 | } 67 | 68 | export default EventCallItem; 69 | -------------------------------------------------------------------------------- /frontend/src/Event/ListEventCalls.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import EventCallItem from "./EventCallItem"; 3 | import { 4 | Icon, 5 | Table, 6 | Ref 7 | } from "semantic-ui-react"; 8 | import "../Call/CallPlayer.css"; 9 | 10 | // ---------------------------------------------------- 11 | const ListEventCalls = (props) => { 12 | const activeCallRef = useRef(); 13 | 14 | //https://stackoverflow.com/questions/36559661/how-can-i-dispatch-from-child-components-in-react-redux 15 | //https://stackoverflow.com/questions/42597602/react-onclick-pass-event-with-parameter 16 | 17 | useEffect(() => { 18 | if (activeCallRef.current) { 19 | activeCallRef.current.scrollIntoView({ 20 | block: "center", 21 | }); 22 | } 23 | }); 24 | 25 | 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | Len 33 | Talkgroup 34 | Time 35 | System 36 | 37 | 38 | {props.eventData && 39 | 40 | 41 | { props.eventData.calls.map((call, index) => { 42 | if (call._id === props.activeCallId) { 43 | return () 44 | } else { 45 | return 46 | } 47 | }) 48 | 49 | } 50 | 51 | 52 | } 53 |
54 | 55 | 56 | ); 57 | 58 | } 59 | 60 | export default ListEventCalls; 61 | -------------------------------------------------------------------------------- /frontend/src/Main/Main.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: didact; 3 | src: url("../resources/DidactGothic-Regular.ttf"); 4 | } 5 | 6 | #menu-bar { 7 | 8 | background-color: #365474; 9 | } 10 | 11 | .header { 12 | font-family: didact !important; 13 | } 14 | 15 | #header-bg { 16 | position: relative; 17 | } 18 | 19 | #hero { 20 | height: 725px; 21 | } 22 | 23 | .z-0 { 24 | z-index: 0; 25 | } 26 | 27 | .z-5 { 28 | z-index: 5; 29 | } 30 | 31 | .absolute { 32 | position: absolute; 33 | } 34 | 35 | .relative { 36 | position: relative; 37 | } 38 | 39 | .static-gradient-bg { 40 | background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB8AAAAfBAMAAADtgAsKAAAAD1BMVEUAAAAAAAAAAAAAAAAAAABPDueNAAAABXRSTlMVGRsSHUz7Vs0AAAErSURBVCjPHY+BDeUgDEPttAM4wACk/QMEdYHSu/1nOjghQIodPxnRfuPnfWQO0F240K5TvRLKdYXvdAIH8ej+0yTEsh1CUYplr+j91ouhCl3WhEnpNuACvW3vUAgic8woHoA/ojO5MN6uN9EPdIJpRu7IBfTN5+I4aIAowPxSu74OAV7gfFuLJVQeAKS41Kes5k4H6tW8uML0yJcjHTE3WepIAfx+8JhJ6aCeRMRK7EQs9RjPxhheOhZL+P//zh8TW4gKArKGY2m3H1xZgq0ykzzdh9vrWPu4c1eNc0bs+suKQAKmtg4gT4kqleFxCzT1zftKUzmYi0CqWynyr4YSwhq2q/s8pf9l+oD9nbeZAXRAlJ0wFMd43ICcpTanOIQXEDTjdK54eqlV+Q9M1SO8F3P79wAAAABJRU5ErkJggg=="); 41 | 42 | width: 100%; 43 | height: 100%; 44 | } 45 | /* 46 | .static-gradient.blue { 47 | background: -webkit-gradient(linear,left top,right top,from(#2754F0),to(#86ACFE)); 48 | background: linear-gradient(to right, #2754F0, #86ACFE); 49 | }*/ 50 | 51 | .static-gradient.blue { 52 | background: -webkit-gradient(linear,left top,right top,from(#9f0000),to(#ff7543)); 53 | background: linear-gradient(to right, #9f0000, #ff7543); 54 | } 55 | 56 | .static-gradient { 57 | width: 100%; 58 | height: 100%; 59 | overflow: hidden; 60 | -webkit-transform: skewY(-7deg); 61 | transform: skewY(-7deg); 62 | -webkit-transform-origin: 0; 63 | transform-origin: 0; 64 | border-bottom: 2px solid rgba(128,128,128,.5); 65 | bottom: 0; 66 | } 67 | 68 | #menu-bg { 69 | 70 | background-color: #3b5f85; 71 | 72 | } 73 | 74 | #footer { 75 | background: -webkit-gradient(linear, bottom, top,from(#9f0000),to(#ff7543)); 76 | background: linear-gradient(to top, #9f0000, #ff7543); 77 | } 78 | 79 | .ui.button { 80 | 81 | 82 | } 83 | -------------------------------------------------------------------------------- /frontend/src/System/InternationList.js: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | Header 4 | } from "semantic-ui-react"; 5 | import { useNavigate } from 'react-router-dom' 6 | import SystemCard from "./SystemCard"; 7 | 8 | 9 | const InternationList = (other, onContactClick) => { 10 | 11 | const navigate = useNavigate(); 12 | let international = []; 13 | international.push((
International
)) 14 | international.push(( 15 | 16 | {other && 17 | other.map((system) => { 18 | return navigate("/system/" + system.shortName)} onContactClick={onContactClick} /> 19 | })} 20 | 21 | )) 22 | return international; 23 | } 24 | 25 | export default InternationList; -------------------------------------------------------------------------------- /frontend/src/System/StateLinkList.js: -------------------------------------------------------------------------------- 1 | import { 2 | 3 | List, 4 | 5 | } from "semantic-ui-react"; 6 | 7 | const StateLinkList = (states) => { 8 | let stateList = []; 9 | if (states) { 10 | let keys = Object.keys(states); 11 | keys.sort(); 12 | 13 | 14 | for (var i = 0; i < keys.length; ++i) { 15 | const state = keys[i]; 16 | stateList.push(( 17 | 18 | 19 | 20 | {state} 21 | 22 | 23 | 24 | )) 25 | } 26 | } 27 | return stateList; 28 | } 29 | 30 | export default StateLinkList -------------------------------------------------------------------------------- /frontend/src/System/SystemsByState.js: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | Header 4 | } from "semantic-ui-react"; 5 | import { useNavigate } from 'react-router-dom' 6 | import SystemCard from "./SystemCard"; 7 | 8 | 9 | const SystemsByState = (states, onContactClick) => { 10 | const navigate = useNavigate(); 11 | let systemsByState = []; 12 | 13 | if (states) { 14 | 15 | let keys = Object.keys(states); 16 | keys.sort(); 17 | 18 | for (var i = 0; i < keys.length; ++i) { 19 | const state = keys[i]; 20 | 21 | systemsByState.push((
{state}
)) 22 | systemsByState.push(( 23 | 24 | {states[state] && 25 | states[state].map((system) => { 26 | return navigate("/system/" + system.shortName)} onContactClick={onContactClick} /> 27 | })} 28 | 29 | )) 30 | } 31 | } 32 | return systemsByState; 33 | } 34 | 35 | export default SystemsByState; -------------------------------------------------------------------------------- /frontend/src/System/TrendingList.js: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | Header 4 | } from "semantic-ui-react"; 5 | import { useNavigate } from 'react-router-dom' 6 | import SystemCard from "./SystemCard"; 7 | 8 | 9 | const TrendingList = (popularSystems, onContactClick) => { 10 | 11 | const navigate = useNavigate(); 12 | let trending = []; 13 | trending.push(()) 14 | trending.push(( 15 | 16 | {popularSystems && 17 | popularSystems.map((system) => { 18 | return navigate("/system/" + system.shortName)} onContactClick={onContactClick} /> 19 | })} 20 | 21 | )) 22 | return trending; 23 | } 24 | 25 | export default TrendingList; -------------------------------------------------------------------------------- /frontend/src/app.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | import { Routes ,Route } from 'react-router-dom'; 4 | import { usePageTracking } from "./tracking"; 5 | 6 | import 'semantic-ui-css/semantic.min.css' 7 | 8 | // Main 9 | import Main from "./Main/Main" 10 | 11 | // System 12 | import ListSystems from "./System/ListSystems" 13 | 14 | // Call 15 | import Calls from "./Call/Calls" 16 | 17 | import AboutComponent from "./About/AboutComponent" 18 | import Terms from "./About/Terms" 19 | 20 | // Event 21 | import ListEvents from "./Event/ListEvents" 22 | import ViewEvent from "./Event/ViewEvent" 23 | import ActivityChart from './Call/BetterActivityChart'; 24 | 25 | 26 | const App = () => { 27 | usePageTracking(); 28 | 29 | return ( 30 | 31 | } /> 32 | } /> 33 | } /> 34 | } /> 35 | } /> 36 | } /> 37 | } /> 38 | } /> 39 | 40 | ) 41 | } 42 | 43 | export default App; -------------------------------------------------------------------------------- /frontend/src/features/group/groupSlice.js: -------------------------------------------------------------------------------- 1 | const groupSlice = createSlice({ 2 | name: 'group', 3 | initialState: [], 4 | reducers: { 5 | }, 6 | }) 7 | 8 | // Extract the action creators object and the reducer 9 | const { actions, reducer } = groupSlice 10 | // Export the reducer, either as a default or named export 11 | export default reducer -------------------------------------------------------------------------------- /frontend/src/features/systems/systemsSlice.js: -------------------------------------------------------------------------------- 1 | 2 | import { 3 | createSlice, 4 | createEntityAdapter, 5 | createSelector 6 | } from '@reduxjs/toolkit' 7 | import { apiSlice} from '../api/apiSlice' 8 | 9 | // Calling `someEndpoint.select(someArg)` generates a new selector that will return 10 | // the query result object for a query with those parameters. 11 | // To generate a selector for a specific query argument, call `select(theQueryArg)`. 12 | // In this case, the users query has no params, so we don't pass anything to select() 13 | export const selectSystemsResult = apiSlice.endpoints.getSystems.select() 14 | 15 | const emptySystems = [] 16 | 17 | export const selectAllSystems = createSelector( 18 | selectSystemsResult, 19 | systemsResult => systemsResult.data?systemsResult.data.systems:emptySystems 20 | ) 21 | 22 | export const selectActiveSystems = createSelector( 23 | selectAllSystems, 24 | (state) => null, 25 | (systems) => systems.filter(system => system.active === true) 26 | ) 27 | 28 | export const selectSystem = createSelector( 29 | selectAllSystems, 30 | (state) => null, 31 | (systems, shortName) => systems.find(system => system.shortName === shortName) 32 | ) -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { BrowserRouter } from "react-router-dom"; 4 | import { Provider } from 'react-redux' 5 | import setupStore from './redux-router/configureStore' 6 | 7 | import App from "./app" 8 | 9 | const store = setupStore(/* provide initial state if any */) 10 | 11 | const container = document.getElementById('root'); 12 | const root = createRoot(container); 13 | root.render( 14 | 15 | 16 | 17 | 18 | 19 | ) -------------------------------------------------------------------------------- /frontend/src/query-string.js: -------------------------------------------------------------------------------- 1 | export default { 2 | parse: function parse(queryString) { 3 | var query = {}; 4 | var pairs = (queryString[0] === '?' ? queryString.substr(1) : queryString).split('&'); 5 | for (var i = 0; i < pairs.length; i++) { 6 | var pair = pairs[i].split('='); 7 | query[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || ''); 8 | } 9 | return query; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/redux-router/configureStore.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit' 2 | import { apiSlice } from "../features/api/apiSlice" 3 | import callPlayerSlice from "../features/callPlayer/callPlayerSlice" 4 | import { callsReducer } from "../features/calls/callsSlice" 5 | 6 | const setupStore = (preloadedState) => { 7 | const store = configureStore({ 8 | reducer: { 9 | callPlayer: callPlayerSlice, 10 | calls: callsReducer, 11 | [apiSlice.reducerPath]: apiSlice.reducer, 12 | }, 13 | middleware: (getDefaultMiddleware) => 14 | // adding the api middleware enables caching, invalidation, polling and other features of `rtk-query` 15 | getDefaultMiddleware().concat(apiSlice.middleware), 16 | preloadedState, 17 | }) 18 | 19 | return store 20 | } 21 | 22 | export default setupStore; -------------------------------------------------------------------------------- /frontend/src/resources/DidactGothic-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/frontend/src/resources/DidactGothic-Regular.ttf -------------------------------------------------------------------------------- /frontend/src/resources/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openmhz/trunk-server/4ff3038068dae77ea8069f68fdab598673cfa4a2/frontend/src/resources/logo.png -------------------------------------------------------------------------------- /frontend/src/tracking.js: -------------------------------------------------------------------------------- 1 | 2 | import { useEffect } from "react"; 3 | import { useLocation } from "react-router-dom"; 4 | 5 | export const usePageTracking = () => { 6 | const location = useLocation(); 7 | /* 8 | useEffect(() => { 9 | window.gtag("event", "page_view", { 10 | page_path: location.pathname + location.search + location.hash, 11 | page_search: location.search, 12 | page_hash: location.hash, 13 | }); 14 | }, [location]);*/ 15 | }; -------------------------------------------------------------------------------- /mongo/Dockerfile: -------------------------------------------------------------------------------- 1 | # Create proxy container for www.example.com 2 | # 3 | # docker build -t mongo . 4 | 5 | FROM mongo:8-noble 6 | 7 | MAINTAINER Luke Berndt 8 | 9 | # Set timezone 10 | ENV TZ=America/New_York 11 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 12 | 13 | 14 | # Add main NGINX config 15 | RUN mkdir -p /app 16 | ADD totals.js /app 17 | ADD init_test_db.js /app 18 | ADD upgrade_db_admin.js /app 19 | ADD permissions.js /app 20 | ADD errors.js /app 21 | ADD clean.js /app 22 | ADD remove_old_systems.js /app 23 | 24 | ENV GLIBC_TUNABLES=glibc.pthread.rseq=0 25 | 26 | WORKDIR /app 27 | -------------------------------------------------------------------------------- /mongo/clean.js: -------------------------------------------------------------------------------- 1 | db = db.getMongo().getDB( "scanner" ); 2 | 3 | var bulkRemove = db.calls.initializeUnorderedBulkOp() 4 | 5 | var date = new Date(); 6 | date.setMonth(date.getMonth() - 1); 7 | 8 | bulkRemove.find({"time":{$lt: date}}).remove(); 9 | 10 | var results = bulkRemove.execute(); 11 | print("Removed: " + results.nRemoved); 12 | -------------------------------------------------------------------------------- /mongo/crontab: -------------------------------------------------------------------------------- 1 | # Jobs: 2 | # m h dom mon dow command 3 | * 3 * * * root mongo /app/trim.js 4 | # An empty line is required at the end of this file for a valid cron file. 5 | -------------------------------------------------------------------------------- /mongo/errors.js: -------------------------------------------------------------------------------- 1 | db = db.getMongo().getDB( "scanner" ); 2 | var start = new Date(); 3 | start.setHours(start.getHours() - 1); 4 | var results = db.calls.aggregate([{ 5 | $match: { 6 | time: { 7 | $gte: start 8 | }, 9 | shortName: 'dcfd' 10 | } 11 | }, 12 | 13 | { 14 | "$unwind": "$freqList" 15 | }, 16 | { 17 | "$project": { 18 | "freqList": 1, 19 | "errorRatio": { 20 | $cond: [{ 21 | $eq: ["$freqList.len", 0] 22 | }, 0, { 23 | "$multiply": [{ 24 | "$divide": ["$freqList.errors", "$freqList.len"] 25 | }, 100] 26 | }] 27 | }, 28 | "time": "$time" 29 | } 30 | }, 31 | { 32 | "$group": { 33 | "_id": "$freqList.freq", 34 | "values": { 35 | "$push": { 36 | "time": "$time", 37 | "errors": "$errorRatio", 38 | "spikes": "$freqList.spikes" 39 | } 40 | } 41 | } 42 | }, 43 | { 44 | $sort: { 45 | _id: -1 46 | } 47 | }]); 48 | 49 | results.forEach(function(document) { 50 | printjson(document); 51 | }); 52 | -------------------------------------------------------------------------------- /mongo/init_test_db.js: -------------------------------------------------------------------------------- 1 | db = db.getMongo().getDB( "scanner" ); 2 | 3 | db.users.insertOne({ "email" : "test@test.com", "admin" : true, "location" : "somewhere", "lastName" : "Account", "firstName" : "Test", "lastLogin" : ISODate("2017-01-15T13:31:02.148Z"), "local" : { "password" : "$2a$08$8F/8dlU4XJUSMe4OcGVVAeCVAlygxerXteLRXB5SsbkQkoS8l2xG6", "email" : "test@test.com" } }); 4 | db.systems.insertOne({ "key" : "ed8b444011047a67445f16d73088ef34ecec2b46b959a3b6857dd004a5d17bc7", "user" : "test@test.com", "description" : "Radio system for DC Fire", "state" : "DC", "city" : "Washingtond", "shortName" : "dcfd", "name" : "DC Fire and EMS", "active" : true }); 5 | -------------------------------------------------------------------------------- /mongo/permissions.js: -------------------------------------------------------------------------------- 1 | db = db.getMongo().getDB( "scanner" ); 2 | var requests = []; 3 | var permissions = []; 4 | db.systems.find().forEach(system => { 5 | 6 | printjson(system); 7 | if (system && system.user) { 8 | var user = db.users.findOne({'local.email': system.user}) 9 | if (user) { 10 | requests.push( { 11 | 'updateOne': { 12 | 'filter': { '_id': system._id }, 13 | 'update': { '$set': { 'owner': user._id } } 14 | } 15 | }); 16 | permissions.push({ 17 | 'replaceOne': 18 | { 19 | "filter": {'userId':user._id, 'systemId': system._id}, 20 | "replacement" : { '$set': {'userId': user._id, 'systemId': system._id, 'shortName': system.shortName, 'role': 20}}, 21 | "upsert" : true 22 | } 23 | 24 | } 25 | ) 26 | } 27 | } 28 | }); 29 | 30 | if(requests.length > 0) { 31 | db.systems.bulkWrite(requests); 32 | db.permissions.bulkWrite(permissions); 33 | } 34 | -------------------------------------------------------------------------------- /mongo/remove_old_systems.js: -------------------------------------------------------------------------------- 1 | // inactive-systems.js 2 | // This script finds inactive systems and removes them along with their related groups and talkgroups 3 | // Run with: mongosh scanner inactive-systems.js 4 | 5 | // Switch to scanner database 6 | db = db.getSiblingDB('scanner'); 7 | 8 | // Calculate date 6 months ago 9 | const sixMonthsAgo = new Date(); 10 | sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6); 11 | 12 | // Print the date we're using as threshold 13 | print("Finding systems inactive since: " + sixMonthsAgo.toISOString()); 14 | 15 | // Run the aggregation to get inactive systems 16 | const result = db.systems.aggregate([ 17 | { 18 | $match: { 19 | $or: [ 20 | { lastActive: { $lt: sixMonthsAgo } }, 21 | { lastActive: { $exists: false } } 22 | ] 23 | } 24 | }, 25 | { 26 | $project: { 27 | _id: 0, 28 | shortName: 1 29 | } 30 | }, 31 | { 32 | $group: { 33 | _id: null, 34 | inactiveShortNames: { $push: "$shortName" } 35 | } 36 | }, 37 | { 38 | $project: { 39 | _id: 0, 40 | inactiveShortNames: 1 41 | } 42 | } 43 | ]).toArray(); 44 | 45 | // If we found inactive systems, process them 46 | if (result.length > 0) { 47 | const inactiveShortNames = result[0].inactiveShortNames; 48 | print("\nInactive systems found: " + inactiveShortNames.length); 49 | print("\nShort names of inactive systems:"); 50 | print(JSON.stringify(inactiveShortNames, null, 2)); 51 | 52 | // Remove groups associated with inactive systems 53 | const deleteGroupsResult = db.groups.deleteMany({ 54 | shortName: { $in: inactiveShortNames } 55 | }); 56 | 57 | // Remove talkgroups associated with inactive systems 58 | const deleteTalkgroupsResult = db.talkgroups.deleteMany({ 59 | shortName: { $in: inactiveShortNames } 60 | }); 61 | 62 | // Remove the inactive systems themselves 63 | const deleteSystemsResult = db.systems.deleteMany({ 64 | $or: [ 65 | { lastActive: { $lt: sixMonthsAgo } }, 66 | { lastActive: { $exists: false } } 67 | ] 68 | }); 69 | 70 | print("\nCleanup results:"); 71 | print(`Deleted ${deleteGroupsResult.deletedCount} groups associated with inactive systems`); 72 | print(`Deleted ${deleteTalkgroupsResult.deletedCount} talkgroups associated with inactive systems`); 73 | print(`Deleted ${deleteSystemsResult.deletedCount} inactive systems`); 74 | } else { 75 | print("No inactive systems found"); 76 | } -------------------------------------------------------------------------------- /mongo/remove_tg.js: -------------------------------------------------------------------------------- 1 | const B2 = require('backblaze-b2'); 2 | const { MongoClient } = require("mongodb"); 3 | 4 | // Replace the uri string with your connection string. 5 | const uri = "mongodb://localhost:27017"; 6 | const client = new MongoClient(uri); 7 | 8 | 9 | 10 | const b2 = new B2({ 11 | applicationKeyId: '', // or accountId: 'accountId' 12 | applicationKey: '' // or masterApplicationKey 13 | }); 14 | 15 | 16 | async function GetBucket() { 17 | try { 18 | await b2.authorize(); // must authorize first (authorization lasts 24 hrs) 19 | let response = await b2.getBucket({ bucketName: 'openmhz-s3' }); 20 | console.log(response.data); 21 | bucketId = response.data.buckets[0].bucketId; 22 | console.log(bucketId) 23 | return bucketId; 24 | } catch (err) { 25 | console.log('Error getting bucket:', err); 26 | } 27 | } 28 | 29 | async function run() { 30 | try { 31 | let bucketId = await GetBucket(); 32 | const database = client.db('scanner'); 33 | const calls = database.collection('calls'); 34 | await b2.authorize(); 35 | // Query for a movie that has the title 'Back to the Future' 36 | const query = { 37 | shortName: "hennearmer", 38 | talkgroupNum: 3423}; 39 | // Execute query 40 | const cursor = calls.find(query); 41 | // Print a message if no documents were found 42 | if ((await calls.countDocuments(query)) === 0) { 43 | console.log("No documents found!"); 44 | } 45 | // Print returned documents 46 | for await (const doc of cursor) { 47 | console.dir(doc); 48 | let response = await b2.hideFile({ 49 | bucketId: bucketId, 50 | fileName: doc.objectKey 51 | // ...common arguments (optional) 52 | 53 | }); 54 | console.log(response.data); 55 | } 56 | const result = await calls.deleteMany(query); 57 | // Print the number of deleted documents 58 | console.log("Deleted " + result.deletedCount + " documents"); 59 | } finally { 60 | // Ensures that the client will close when you finish/error 61 | await client.close(); 62 | } 63 | } 64 | 65 | run().catch(console.dir); 66 | -------------------------------------------------------------------------------- /mongo/totals.js: -------------------------------------------------------------------------------- 1 | db = db.getMongo().getDB( "scanner" ); 2 | var results = db.calls.aggregate( 3 | [ { 4 | $project: { 5 | len: true, 6 | year: {$year: "$time"}, 7 | month: {$month: "$time"}, 8 | dayOfMonth: {$dayOfMonth: "$time"} 9 | } 10 | }, 11 | { 12 | $group: 13 | { 14 | _id: { 15 | year: '$year', 16 | month: '$month', 17 | dayOfMonth: '$dayOfMonth' 18 | }, 19 | totalAudio: { $sum: "$len" }, 20 | count: { $sum: 1 } 21 | } 22 | } 23 | ] 24 | ) 25 | 26 | results.forEach(function(document) { 27 | printjson(document); 28 | }); 29 | -------------------------------------------------------------------------------- /mongo/upgrade_db_admin.js: -------------------------------------------------------------------------------- 1 | db = db.getMongo().getDB( "scanner" ); 2 | 3 | 4 | db.users.find().forEach( 5 | function(user){ 6 | 7 | db.systems.updateMany( 8 | { "user": { $eq: user.email } }, 9 | { 10 | $set: { "userId": user._id, "showDisplayName": false, "confirmEmail": true }, 11 | } 12 | ); 13 | 14 | print("Upgrading: " + user.email); 15 | }); 16 | -------------------------------------------------------------------------------- /nginx-proxy/Dockerfile: -------------------------------------------------------------------------------- 1 | # Create proxy container for www.example.com 2 | # 3 | # docker build -t nginx-proxy . 4 | 5 | FROM nginx 6 | ARG STAGE 7 | 8 | MAINTAINER Luke Berndt 9 | 10 | # Set timezone 11 | RUN echo "America/New_York" > /etc/timezone \ 12 | && dpkg-reconfigure -f noninteractive tzdata 13 | 14 | # Install wget and install/updates certificates 15 | RUN apt-get update \ 16 | && apt-get install -y -q --no-install-recommends \ 17 | ca-certificates \ 18 | wget \ 19 | && apt-get clean \ 20 | && rm -r /var/lib/apt/lists/* 21 | 22 | # Add main NGINX config 23 | COPY *.conf /etc/nginx/ 24 | 25 | # Add virtual hosts 26 | COPY ${STAGE}/vhosts/ /etc/nginx/conf.d/ 27 | -------------------------------------------------------------------------------- /nginx-proxy/cert/vhosts/site.template: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name account.${DOMAIN_NAME} api.${DOMAIN_NAME} admin.${DOMAIN_NAME} www.${DOMAIN_NAME} ${DOMAIN_NAME}; 4 | 5 | location /.well-known/acme-challenge/ { 6 | root /var/www/certbot; 7 | } 8 | } -------------------------------------------------------------------------------- /nginx-proxy/nginx.conf: -------------------------------------------------------------------------------- 1 | user www-data; 2 | worker_processes 3; ## default is 1 3 | 4 | pid /var/run/nginx.pid; 5 | 6 | events { 7 | worker_connections 4096; ## Default: 1024 8 | } 9 | 10 | 11 | error_log /dev/stdout error; 12 | 13 | 14 | http { 15 | log_format main '$http_host ' 16 | '$remote_addr ' 17 | '"$request" $status $body_bytes_sent ' 18 | '"$http_referer" "$http_user_agent" '; 19 | #access_log /dev/stdout main; 20 | #access_log off; 21 | 22 | include /etc/nginx/mime.types; 23 | default_type application/octet-stream; 24 | 25 | sendfile on; 26 | keepalive_timeout 65; 27 | 28 | gzip on; 29 | gzip_disable "msie6"; 30 | gzip_vary on; 31 | gzip_proxied any; 32 | gzip_comp_level 6; 33 | gzip_buffers 16 8k; 34 | gzip_http_version 1.1; 35 | gzip_types text/plain text/css application/json application/x-javascript 36 | text/xml application/xml application/xml+rss text/javascript; 37 | 38 | include /etc/nginx/conf.d/*.conf; 39 | } 40 | 41 | -------------------------------------------------------------------------------- /nginx-proxy/proxy.conf: -------------------------------------------------------------------------------- 1 | proxy_redirect off; 2 | proxy_set_header Host $host; 3 | proxy_set_header X-Real-IP $remote_addr; 4 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 5 | client_max_body_size 10m; 6 | client_body_buffer_size 128k; 7 | proxy_connect_timeout 90; 8 | proxy_send_timeout 90; 9 | proxy_read_timeout 90; 10 | proxy_buffers 32 4k; 11 | # WebSocket support (nginx 1.4) 12 | #proxy_http_version 1.1; 13 | #proxy_set_header Upgrade $http_upgrade; 14 | #proxy_set_header Connection "upgrade"; 15 | -------------------------------------------------------------------------------- /nginx-proxy/test/vhosts/site.template: -------------------------------------------------------------------------------- 1 | 2 | server { 3 | listen 80; 4 | 5 | server_name ${DOMAIN_NAME} www.${DOMAIN_NAME}; 6 | 7 | 8 | location /.well-known/acme-challenge/ { 9 | root /var/www/certbot; 10 | } 11 | 12 | location /static { 13 | proxy_pass http://frontend:3000; 14 | access_log off; 15 | } 16 | 17 | location /favicon.ico { 18 | proxy_pass http://frontend:3000; 19 | expires 1M; 20 | add_header Surrogate-Control "public, max-age=604800"; 21 | add_header Cache-Control "max-age=604800, public"; 22 | access_log /dev/stdout main; 23 | } 24 | 25 | location ~* \.(gif|jpg|png)$ { 26 | proxy_pass http://frontend:3000; 27 | 28 | add_header Pragma public; 29 | add_header Surrogate-Control "public, max-age=604800"; 30 | add_header Cache-Control "max-age=604800, public"; 31 | expires 1M; 32 | } 33 | 34 | location /silence.m4a { 35 | proxy_pass http://frontend:3000; 36 | access_log /dev/stdout main; 37 | 38 | add_header Surrogate-Control "public, max-age=604800"; 39 | add_header Cache-Control "max-age=604800, public"; 40 | expires 1M; 41 | } 42 | 43 | location / { 44 | proxy_pass http://frontend:3000; 45 | include /etc/nginx/proxy.conf; 46 | access_log /dev/stdout main; 47 | } 48 | } 49 | 50 | server { 51 | listen 80; 52 | 53 | server_name account.${DOMAIN_NAME}; 54 | 55 | location /.well-known/acme-challenge/ { 56 | root /var/www/certbot; 57 | } 58 | 59 | location / { 60 | proxy_pass http://account:3009; 61 | include /etc/nginx/proxy.conf; 62 | access_log off; 63 | } 64 | } 65 | 66 | server { 67 | listen 80; 68 | 69 | server_name admin.${DOMAIN_NAME}; 70 | 71 | location /.well-known/acme-challenge/ { 72 | root /var/www/certbot; 73 | } 74 | 75 | location / { 76 | proxy_pass http://admin:3008; 77 | include /etc/nginx/proxy.conf; 78 | access_log off; 79 | } 80 | } 81 | 82 | server { 83 | listen 80; 84 | 85 | server_name api.${DOMAIN_NAME}; 86 | 87 | location /.well-known/acme-challenge/ { 88 | root /var/www/certbot; 89 | } 90 | 91 | location / { 92 | proxy_pass http://backend:3005; 93 | include /etc/nginx/proxy.conf; 94 | proxy_http_version 1.1; 95 | proxy_set_header Upgrade $http_upgrade; 96 | proxy_set_header Connection "upgrade"; 97 | proxy_set_header Host $host; 98 | proxy_cache_bypass $http_upgrade; 99 | access_log /dev/stdout main; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /node_modules/.package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trunk-server", 3 | "lockfileVersion": 2, 4 | "requires": true, 5 | "packages": {} 6 | } 7 | -------------------------------------------------------------------------------- /node_modules/.yarn-integrity: -------------------------------------------------------------------------------- 1 | { 2 | "systemParams": "darwin-arm64-93", 3 | "modulesFolders": [], 4 | "flags": [], 5 | "linkedModules": [], 6 | "topLevelPatterns": [], 7 | "lockfileEntries": {}, 8 | "files": [], 9 | "artifacts": {} 10 | } -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trunk-server", 3 | "lockfileVersion": 2, 4 | "requires": true, 5 | "packages": {} 6 | } 7 | -------------------------------------------------------------------------------- /prod.env.example: -------------------------------------------------------------------------------- 1 | export MAILJET_KEY="FILL IN HERE" 2 | export MAILJET_SECRET="FILL IN HERE" 3 | export REACT_APP_SITE_NAME=OpenMHz 4 | export REACT_APP_ADMIN_EMAIL="admin@email.com" 5 | export DOMAIN_NAME="openmhz.com" 6 | 7 | export S3_PROFILE='default' 8 | export S3_REGION='us-west-001' 9 | export S3_ENDPOINT='https://s3.us-west-1.wasabisys.com' 10 | export S3_BUCKET='openmhz-test' 11 | 12 | export TAG=2.0 13 | export REACT_APP_ARCHIVE_DAYS=30 14 | 15 | export STAGE=prod 16 | export NODE_ENV="production" 17 | export PROTOCOL="https://" 18 | 19 | export REACT_APP_BACKEND_SERVER="${PROTOCOL}api.${DOMAIN_NAME}" 20 | export REACT_APP_FRONTEND_SERVER="${PROTOCOL}${DOMAIN_NAME}" 21 | export REACT_APP_ACCOUNT_SERVER="${PROTOCOL}account.${DOMAIN_NAME}" 22 | export REACT_APP_ADMIN_SERVER="${PROTOCOL}admin.${DOMAIN_NAME}" 23 | export REACT_APP_COOKIE_DOMAIN=".${DOMAIN_NAME}" 24 | export REACT_APP_GOOGLE_ANALYTICS="" 25 | 26 | 27 | -------------------------------------------------------------------------------- /rebuild.sh: -------------------------------------------------------------------------------- 1 | touch frontend/src/index.js 2 | touch account/src/index.js 3 | touch admin/src/index.js -------------------------------------------------------------------------------- /test-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | nginx: 4 | logging: 5 | driver: "json-file" 6 | options: 7 | tag: "nginx" 8 | 9 | mongo: 10 | logging: 11 | driver: "json-file" 12 | options: 13 | tag: "mongo" 14 | 15 | backend: 16 | logging: 17 | driver: "json-file" 18 | options: 19 | tag: "backend" 20 | command: ["nodemon", "index.js"] 21 | 22 | admin: 23 | logging: 24 | driver: "json-file" 25 | options: 26 | tag: "admin" 27 | command: ["nodemon", "index.js"] 28 | 29 | account: 30 | logging: 31 | driver: "json-file" 32 | options: 33 | tag: "account" 34 | command: ["nodemon", "index.js"] 35 | 36 | frontend: 37 | logging: 38 | driver: "json-file" 39 | options: 40 | tag: "frontend" 41 | command: ["nodemon", "index.js"] 42 | 43 | networks: 44 | proxy: 45 | node: 46 | -------------------------------------------------------------------------------- /test.env.example: -------------------------------------------------------------------------------- 1 | export MAILJET_KEY="FILL IN HERE" 2 | export MAILJET_SECRET="FILL IN HERE" 3 | export REACT_APP_SITE_NAME=OpenMHz 4 | export REACT_APP_ADMIN_EMAIL="admin@email.com" 5 | export DOMAIN_NAME="openmhz.test" 6 | 7 | export S3_PROFILE='default' 8 | export S3_REGION='us-west-001' 9 | export S3_ENDPOINT='https://s3.us-west-1.wasabisys.com' 10 | export S3_BUCKET='openmhz-test' 11 | 12 | export TAG=2.0 13 | export REACT_APP_ARCHIVE_DAYS=30 14 | 15 | export STAGE=test 16 | export NODE_ENV="test" 17 | export PROTOCOL="http://" 18 | 19 | export REACT_APP_BACKEND_SERVER="${PROTOCOL}api.${DOMAIN_NAME}" 20 | export REACT_APP_FRONTEND_SERVER="${PROTOCOL}${DOMAIN_NAME}" 21 | export REACT_APP_ACCOUNT_SERVER="${PROTOCOL}account.${DOMAIN_NAME}" 22 | export REACT_APP_ADMIN_SERVER="${PROTOCOL}admin.${DOMAIN_NAME}" 23 | export REACT_APP_COOKIE_DOMAIN=".${DOMAIN_NAME}" 24 | 25 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | --------------------------------------------------------------------------------