├── .dockerignore ├── .github └── workflows │ ├── build-ui.yml │ ├── ci.yml │ ├── docker.yml │ └── docs.yml ├── .gitignore ├── .husky └── pre-commit ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── Taskfile.yaml ├── api ├── api.go ├── api_suite_test.go ├── auth.go ├── auth_forward.go ├── auth_test.go ├── channels.go ├── channels_test.go ├── dvr.go ├── epg.go ├── logging.go ├── login.go ├── logout.go ├── picon.go ├── profile.go ├── recordings.go ├── request │ ├── context.go │ ├── context_test.go │ └── request.go ├── response │ ├── errors.go │ └── response.go ├── session.go ├── streaming.go ├── token.go ├── two_factor_auth.go └── user.go ├── cmd ├── admin │ ├── admin.go │ └── user │ │ ├── add.go │ │ ├── delete.go │ │ ├── list.go │ │ ├── token │ │ ├── generate.go │ │ ├── list.go │ │ ├── revoke.go │ │ └── token.go │ │ ├── twofa │ │ ├── disable.go │ │ └── twofa.go │ │ └── user.go ├── cmd.go ├── common │ └── common.go └── server │ └── server.go ├── compose.yml ├── config.example.yaml ├── config ├── auth.go ├── config.go ├── config_test.go ├── database.go ├── logging.go ├── metrics.go ├── server.go └── tvheadend.go ├── conv ├── conv.go └── conv_test.go ├── core ├── auth.go ├── channel.go ├── channel_test.go ├── clock.go ├── common.go ├── dvr_config.go ├── dvr_config_test.go ├── epg.go ├── epg_test.go ├── errors.go ├── picon.go ├── profile.go ├── query.go ├── query_test.go ├── recording.go ├── recording_test.go ├── session.go ├── streaming.go ├── token.go ├── two_factor_settings.go └── user.go ├── db ├── db.go ├── migration │ ├── migration.go │ ├── postgres │ │ ├── 001_create_table_user.down.sql │ │ ├── 001_create_table_user.up.sql │ │ ├── 002_create_table_session.down.sql │ │ ├── 002_create_table_session.up.sql │ │ ├── 004_create_table_two_factor_settings.down.sql │ │ ├── 004_create_table_two_factor_settings.up.sql │ │ ├── 005_create_table_token.down.sql │ │ ├── 005_create_table_token.up.sql │ │ ├── 006_add_admin_column.down.sql │ │ └── 006_add_admin_column.up.sql │ └── sqlite3 │ │ ├── 001_create_table_user.down.sql │ │ ├── 001_create_table_user.up.sql │ │ ├── 002_create_table_session.down.sql │ │ ├── 002_create_table_session.up.sql │ │ ├── 004_create_table_two_factor_settings.down.sql │ │ ├── 004_create_table_two_factor_settings.up.sql │ │ ├── 005_create_table_token.down.sql │ │ ├── 005_create_table_token.up.sql │ │ ├── 006_add_admin_column.down.sql │ │ └── 006_add_admin_column.up.sql └── testdb │ └── testdb.go ├── docs ├── api │ ├── docs.go │ ├── swagger.json │ └── swagger.yaml ├── auth.md ├── cli │ ├── docs.go │ └── docs.md ├── configuration.md ├── fail2ban.md ├── images │ ├── grafana-dashboard.png │ └── tvhgo.png ├── index.md ├── installation.md ├── installation │ ├── docker.md │ └── helm.md ├── metrics.md ├── rapidoc.html ├── screenshots │ ├── channel_events.png │ ├── channel_events_mobile.png │ ├── channels.png │ ├── channels_mobile.png │ ├── event.png │ ├── event_mobile.png │ ├── guide.png │ ├── guide_mobile.png │ └── overview.png └── tvhgo-grafana.json ├── go.mod ├── go.sum ├── health ├── health.go └── tvheadend.go ├── main.go ├── metrics ├── metrics.go ├── server.go ├── tvheadend.go └── tvheadend_test.go ├── mkdocs.yml ├── mock ├── core │ ├── mock.go │ └── mock_gen.go └── tvheadend │ ├── mock.go │ └── mock_gen.go ├── package.json ├── renovate.json ├── repository ├── scanner.go ├── session │ ├── query.go │ ├── scan.go │ ├── session.go │ └── session_test.go ├── token │ ├── query.go │ ├── scan.go │ ├── token.go │ └── token_test.go ├── two_factor_settings │ ├── query.go │ ├── scan.go │ ├── two_factor_settings.go │ └── two_factor_settings_test.go └── user │ ├── query.go │ ├── scan.go │ ├── user.go │ └── user_test.go ├── services ├── auth │ ├── auth.go │ ├── local.go │ ├── local_test.go │ ├── session.go │ ├── session_cleaner.go │ ├── session_test.go │ ├── token.go │ ├── token_test.go │ ├── two_factor_auth.go │ └── two_factor_auth_test.go ├── channel │ ├── channel.go │ └── channel_test.go ├── clock │ └── clock.go ├── dvr │ └── dvr_config.go ├── epg │ ├── epg.go │ └── epg_test.go ├── picon │ ├── picon.go │ └── picon_test.go ├── profile │ └── profile.go ├── recording │ ├── recording.go │ └── recording_test.go └── streaming │ └── streaming.go ├── tvheadend ├── client.go ├── client_test.go └── types.go ├── ui ├── .eslintrc.cjs ├── .prettierrc.cjs ├── dist │ └── keep ├── index.html ├── package.json ├── public │ ├── favicon.ico │ ├── img │ │ ├── logo192.png │ │ ├── logo512.png │ │ └── tvhgo.png │ └── manifest.webmanifest ├── src │ ├── App.tsx │ ├── __test__ │ │ └── ids.ts │ ├── assets │ │ ├── arrow_right.svg │ │ ├── burger_menu.svg │ │ ├── checkmark.svg │ │ ├── close.svg │ │ ├── copy.svg │ │ ├── dash.svg │ │ ├── guide.svg │ │ ├── index.ts │ │ ├── large_arrow_left.svg │ │ ├── large_arrow_right.svg │ │ ├── logout.svg │ │ ├── rec.svg │ │ ├── recordings.svg │ │ ├── search.svg │ │ ├── settings.svg │ │ ├── tv.svg │ │ └── tvhgo_horizontal.svg │ ├── clients │ │ └── api │ │ │ ├── api.ts │ │ │ └── api.types.ts │ ├── components │ │ ├── channels │ │ │ └── listItem │ │ │ │ ├── ChannelListItem.module.scss │ │ │ │ └── ChannelListItem.tsx │ │ ├── common │ │ │ ├── badge │ │ │ │ ├── Badge.module.scss │ │ │ │ └── Badge.tsx │ │ │ ├── button │ │ │ │ ├── Button.module.scss │ │ │ │ ├── Button.tsx │ │ │ │ └── ButtonLink.tsx │ │ │ ├── checkbox │ │ │ │ ├── Checkbox.module.scss │ │ │ │ └── Checkbox.tsx │ │ │ ├── deleteConfirmationModal │ │ │ │ ├── DeleteConfirmationModal.module.scss │ │ │ │ └── DeleteConfirmationModal.tsx │ │ │ ├── dropdown │ │ │ │ ├── Dropdown.module.scss │ │ │ │ └── Dropdown.tsx │ │ │ ├── emptyState │ │ │ │ ├── EmptyState.module.scss │ │ │ │ └── EmptyState.tsx │ │ │ ├── error │ │ │ │ ├── Error.module.scss │ │ │ │ └── Error.tsx │ │ │ ├── form │ │ │ │ ├── Form.module.scss │ │ │ │ ├── Form.tsx │ │ │ │ └── FormGroup │ │ │ │ │ ├── FormGroup.module.scss │ │ │ │ │ └── FormGroup.tsx │ │ │ ├── headline │ │ │ │ ├── Headline.module.scss │ │ │ │ └── Headline.tsx │ │ │ ├── image │ │ │ │ └── Image.tsx │ │ │ ├── input │ │ │ │ ├── Input.module.scss │ │ │ │ └── Input.tsx │ │ │ ├── loading │ │ │ │ ├── Loading.module.scss │ │ │ │ └── Loading.tsx │ │ │ ├── modal │ │ │ │ ├── Modal.module.scss │ │ │ │ ├── Modal.tsx │ │ │ │ ├── ModalCloseButton.module.scss │ │ │ │ └── ModalCloseButton.tsx │ │ │ ├── paginationControls │ │ │ │ ├── PaginationControlButton.module.scss │ │ │ │ ├── PaginationControlButton.tsx │ │ │ │ ├── PaginationControls.module.scss │ │ │ │ └── PaginationControls.tsx │ │ │ ├── pairList │ │ │ │ ├── Pair │ │ │ │ │ ├── Pair.module.scss │ │ │ │ │ └── Pair.tsx │ │ │ │ ├── PairKey │ │ │ │ │ ├── PairKey.module.scss │ │ │ │ │ └── PairKey.tsx │ │ │ │ ├── PairList.module.scss │ │ │ │ ├── PairList.tsx │ │ │ │ └── PairValue │ │ │ │ │ ├── PairValue.module.scss │ │ │ │ │ └── PairValue.tsx │ │ │ └── table │ │ │ │ ├── Table.module.scss │ │ │ │ ├── Table.tsx │ │ │ │ ├── TableBody.tsx │ │ │ │ ├── TableCell.module.scss │ │ │ │ ├── TableCell.tsx │ │ │ │ ├── TableHead.module.scss │ │ │ │ ├── TableHead.tsx │ │ │ │ ├── TableHeadCell.module.scss │ │ │ │ ├── TableHeadCell.tsx │ │ │ │ ├── TableRow.module.scss │ │ │ │ └── TableRow.tsx │ │ ├── epg │ │ │ ├── event │ │ │ │ ├── channelInfo │ │ │ │ │ ├── EventChannelInfo.module.scss │ │ │ │ │ └── EventChannelInfo.tsx │ │ │ │ ├── info │ │ │ │ │ ├── EventInfo.module.scss │ │ │ │ │ └── EventInfo.tsx │ │ │ │ ├── recordButton │ │ │ │ │ ├── EventRecordButton.module.scss │ │ │ │ │ └── EventRecordButton.tsx │ │ │ │ └── related │ │ │ │ │ ├── EventRelated.module.scss │ │ │ │ │ └── EventRelated.tsx │ │ │ └── guide │ │ │ │ ├── channel │ │ │ │ ├── GuideChannel.module.scss │ │ │ │ └── GuideChannel.tsx │ │ │ │ ├── controls │ │ │ │ ├── GuideControls.module.scss │ │ │ │ └── GuideControls.tsx │ │ │ │ ├── event │ │ │ │ ├── GuideEvent.module.scss │ │ │ │ └── GuideEvent.tsx │ │ │ │ ├── eventColumn │ │ │ │ ├── GuideEventColumn.module.scss │ │ │ │ └── GuideEventColumn.tsx │ │ │ │ └── navigation │ │ │ │ ├── GuideNavigation.module.scss │ │ │ │ └── GuideNavigation.tsx │ │ ├── header │ │ │ ├── Header.module.scss │ │ │ └── Header.tsx │ │ ├── login │ │ │ ├── card │ │ │ │ ├── LoginCard.module.scss │ │ │ │ └── LoginCard.tsx │ │ │ └── footer │ │ │ │ ├── LoginFooter.module.scss │ │ │ │ └── LoginFooter.tsx │ │ ├── navigation │ │ │ ├── bar │ │ │ │ ├── NavigationBar.module.scss │ │ │ │ └── NavigationBar.tsx │ │ │ ├── item │ │ │ │ ├── NavigationItem.module.scss │ │ │ │ └── NavigationItem.tsx │ │ │ └── types.ts │ │ ├── recordings │ │ │ └── listItem │ │ │ │ ├── RecordingListItem.module.scss │ │ │ │ └── RecordingListItem.tsx │ │ └── settings │ │ │ ├── sessionList │ │ │ ├── SessionList.module.scss │ │ │ └── SessionList.tsx │ │ │ ├── tokenList │ │ │ ├── TokenList.module.scss │ │ │ └── TokenList.tsx │ │ │ └── twoFactorAuthSettings │ │ │ ├── TwoFactorAuthSettingsOverview.module.scss │ │ │ └── TwoFactorAuthSettingsOverview.tsx │ ├── contexts │ │ ├── AuthContext.ts │ │ ├── LoadingContext.ts │ │ └── ThemeContext.ts │ ├── hooks │ │ ├── 2fa.ts │ │ ├── dvr.ts │ │ ├── formik.ts │ │ ├── login.ts │ │ ├── logout.ts │ │ ├── notification.ts │ │ ├── pagination.ts │ │ ├── recording.ts │ │ ├── session.ts │ │ ├── token.ts │ │ ├── user.test.ts │ │ └── user.ts │ ├── i18n │ │ ├── i18n.test.tsx │ │ ├── i18n.ts │ │ └── locales │ │ │ ├── de │ │ │ └── translations.json │ │ │ ├── en │ │ │ └── translations.json │ │ │ └── es │ │ │ └── translations.json │ ├── index.scss │ ├── main.tsx │ ├── modals │ │ ├── dvr │ │ │ └── profile │ │ │ │ ├── DVRProfileSelectModal.module.scss │ │ │ │ └── DVRProfileSelectModal.tsx │ │ ├── recording │ │ │ └── create │ │ │ │ ├── RecordingCreateModal.module.scss │ │ │ │ └── RecordingCreateModal.tsx │ │ ├── token │ │ │ └── create │ │ │ │ ├── CreateTokenModal.module.scss │ │ │ │ └── CreateTokenModal.tsx │ │ ├── twoFactorAuth │ │ │ ├── disable │ │ │ │ ├── TwoFactorAuthDisableModal.module.scss │ │ │ │ └── TwoFactorAuthDisableModal.tsx │ │ │ └── setup │ │ │ │ ├── TwoFactorAuthSetupModal.module.scss │ │ │ │ └── TwoFactorAuthSetupModal.tsx │ │ └── user │ │ │ └── create │ │ │ ├── UserCreateModal.module.scss │ │ │ └── UserCreateModal.tsx │ ├── providers │ │ ├── AuthProvider.tsx │ │ ├── LoadingProvider.tsx │ │ └── ThemeProvider.tsx │ ├── setupTests.ts │ ├── utils │ │ ├── classNames.test.ts │ │ └── classNames.ts │ ├── views │ │ ├── channels │ │ │ ├── detail │ │ │ │ ├── ChannelView.module.scss │ │ │ │ └── ChannelView.tsx │ │ │ └── list │ │ │ │ ├── ChannelListView.module.scss │ │ │ │ ├── ChannelListView.test.tsx │ │ │ │ ├── ChannelListView.tsx │ │ │ │ └── __snapshots__ │ │ │ │ └── ChannelListView.test.tsx.snap │ │ ├── dashboard │ │ │ ├── DashboardView.module.scss │ │ │ └── DashboardView.tsx │ │ ├── dvr │ │ │ └── config │ │ │ │ ├── detail │ │ │ │ ├── DVRConfigDetailView.module.scss │ │ │ │ └── DVRConfigDetailView.tsx │ │ │ │ └── list │ │ │ │ ├── DVRConfigListView.module.scss │ │ │ │ └── DVRConfigListView.tsx │ │ ├── epg │ │ │ ├── event │ │ │ │ ├── EventView.module.scss │ │ │ │ ├── EventView.test.tsx │ │ │ │ ├── EventView.tsx │ │ │ │ └── __snapshots__ │ │ │ │ │ └── EventView.test.tsx.snap │ │ │ └── guide │ │ │ │ ├── GuideView.module.scss │ │ │ │ └── GuideView.tsx │ │ ├── login │ │ │ ├── LoginView.module.scss │ │ │ ├── LoginView.test.tsx │ │ │ ├── LoginView.tsx │ │ │ └── __snapshots__ │ │ │ │ └── LoginView.test.tsx.snap │ │ ├── recordings │ │ │ ├── RecordingDetailView │ │ │ │ ├── RecordingDetailView.module.scss │ │ │ │ ├── RecordingDetailView.test.tsx │ │ │ │ ├── RecordingDetailView.tsx │ │ │ │ └── __snapshots__ │ │ │ │ │ └── RecordingDetailView.test.tsx.snap │ │ │ └── RecordingsView │ │ │ │ ├── RecordingsView.module.scss │ │ │ │ ├── RecordingsView.test.tsx │ │ │ │ ├── RecordingsView.tsx │ │ │ │ └── __snapshots__ │ │ │ │ └── RecordingsView.test.tsx.snap │ │ ├── search │ │ │ ├── SearchView.module.scss │ │ │ └── SearchView.tsx │ │ └── settings │ │ │ ├── GeneralSettingsView.test.tsx │ │ │ ├── GeneralSettingsView.tsx │ │ │ ├── SecuritySettingsView.test.tsx │ │ │ ├── SecuritySettingsView.tsx │ │ │ ├── SettingsView.module.scss │ │ │ ├── SettingsView.tsx │ │ │ ├── __snapshots__ │ │ │ ├── GeneralSettingsView.test.tsx.snap │ │ │ └── SecuritySettingsView.test.tsx.snap │ │ │ ├── states.ts │ │ │ └── users │ │ │ ├── detail │ │ │ ├── UserDetailView.module.scss │ │ │ └── UserDetailView.tsx │ │ │ └── list │ │ │ ├── UserListView.module.scss │ │ │ ├── UserListView.tsx │ │ │ └── states.ts │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── ui.go ├── vite.config.ts └── yarn.lock └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | ui/node_modules/ 2 | 3 | config.yaml 4 | tvhgo.db 5 | 6 | Dockerfile 7 | -------------------------------------------------------------------------------- /.github/workflows/build-ui.yml: -------------------------------------------------------------------------------- 1 | name: Build UI 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | upload_artifact: 7 | description: "Upload the build artifact" 8 | required: false 9 | default: false 10 | type: boolean 11 | version: 12 | description: "Version" 13 | required: false 14 | default: "local" 15 | type: string 16 | 17 | env: 18 | NODE_VERSION: 20.12.2 19 | 20 | jobs: 21 | build-ui: 22 | runs-on: ubuntu-latest 23 | defaults: 24 | run: 25 | working-directory: ./ui 26 | 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 30 | 31 | - name: Setup NodeJS 32 | uses: actions/setup-node@v4 33 | with: 34 | node-version: ${{ env.NODE_VERSION }} 35 | 36 | - name: Install dependencies 37 | run: yarn install --frozen-lockfile 38 | 39 | - name: Prepare 40 | id: prep 41 | run: | 42 | echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT 43 | 44 | - name: Lint ui 45 | run: yarn lint 46 | 47 | - name: Test ui 48 | run: yarn test 49 | 50 | - name: Build ui 51 | env: 52 | VERSION: ${{ inputs.version }} 53 | GIT_COMMIT: ${{ steps.prep.outputs.sha_short }} 54 | run: yarn build 55 | 56 | - name: Upload build artifact 57 | if: ${{ inputs.upload_artifact }} 58 | uses: actions/upload-artifact@v4 59 | with: 60 | name: tvhgo-ui-${{ github.sha }} 61 | path: ./ui/dist 62 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | go-tests: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 13 | 14 | - name: Set up Go 15 | uses: actions/setup-go@v5 16 | id: go 17 | with: 18 | go-version: 1.21 19 | 20 | - run: go mod download 21 | - run: go test ./... 22 | 23 | build-ui: 24 | uses: ./.github/workflows/build-ui.yml 25 | with: 26 | upload_artifact: false 27 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v[0-9]+.[0-9]+.[0-9]+' 7 | branches: 8 | - main 9 | paths: 10 | - .github/workflows/** 11 | - mkdocs.yml 12 | - docs/** 13 | 14 | concurrency: 15 | group: ${{ github.workflow }} 16 | 17 | jobs: 18 | deploy: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | 25 | - uses: actions/setup-python@v5 26 | with: 27 | python-version: 3.x 28 | 29 | - name: install tools 30 | run: pip install mkdocs-material mike typing-extensions 31 | 32 | - name: Setup doc deploy 33 | run: | 34 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 35 | git config --local user.name "github-actions[bot]" 36 | 37 | - name: Deploy version 38 | run: | 39 | VERSION="$(sed 's:/:-:g' <<< "$GITHUB_REF_NAME")" 40 | if [[ ${{github.ref}} =~ ^refs/tags/ ]]; then 41 | EXTRA_ALIAS=latest 42 | fi 43 | mike deploy --push --update-aliases "$VERSION" $EXTRA_ALIAS 44 | tr '[:upper:]' '[:lower:]' <<< "https://${{github.repository_owner}}.github.io/${{github.event.repository.name}}/$VERSION/" >> "$GITHUB_STEP_SUMMARY" 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | out/ 3 | 4 | config.yaml 5 | config.yml 6 | *.db 7 | 8 | .venv/ 9 | 10 | # Logs 11 | logs 12 | *.log 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | pnpm-debug.log* 17 | lerna-debug.log* 18 | 19 | node_modules 20 | ui/dist/* 21 | !ui/dist/keep 22 | dist-ssr 23 | *.local 24 | 25 | # Editor directories and files 26 | .vscode/* 27 | !.vscode/extensions.json 28 | .idea 29 | .DS_Store 30 | *.suo 31 | *.ntvs* 32 | *.njsproj 33 | *.sln 34 | *.sw? 35 | 36 | coverage/ 37 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | yarn pre-commit 2 | yarn --cwd ui pre-commit 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24.3-alpine3.20 AS build 2 | RUN apk add --no-cache build-base 3 | 4 | ENV CGO_ENABLED=1 5 | WORKDIR /build 6 | 7 | COPY . . 8 | 9 | RUN go mod download 10 | RUN go build -o tvhgo -tags prod main.go 11 | 12 | FROM alpine:3.22 AS prod 13 | 14 | COPY --from=build /build/tvhgo /bin/tvhgo 15 | 16 | RUN adduser -D -H tvhgo 17 | 18 | WORKDIR /tvhgo 19 | RUN chown -R tvhgo:tvhgo /tvhgo 20 | 21 | USER tvhgo 22 | EXPOSE 8080 23 | VOLUME ["/tvhgo"] 24 | ENV TVHGO_DATABASE_PATH=/tvhgo/tvhgo.db 25 | 26 | ENTRYPOINT ["/bin/tvhgo"] 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 David Borzek 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Taskfile.yaml: -------------------------------------------------------------------------------- 1 | # https://taskfile.dev 2 | 3 | version: "3" 4 | 5 | tasks: 6 | deps-ui: 7 | dir: ./ui 8 | cmds: 9 | - yarn 10 | 11 | deps: 12 | cmds: 13 | - go mod download 14 | - task: deps-ui 15 | 16 | test: 17 | cmds: 18 | - go test ./... 19 | 20 | build-ui: 21 | dir: ./ui 22 | cmds: 23 | - task: deps-ui 24 | - yarn build 25 | 26 | build: 27 | cmds: 28 | - task: build-ui 29 | - go build -o out/tvhgo -tags prod main.go 30 | 31 | docker: 32 | vars: 33 | IMAGE: ghcr.io/davidborzek/tvhgo 34 | GIT_COMMIT: 35 | sh: git rev-parse --short HEAD 36 | cmds: 37 | - cmd: docker rmi {{.IMAGE}} 38 | ignore_error: true 39 | - cmd: docker rmi {{.IMAGE}}:{{.GIT_COMMIT}} 40 | ignore_error: true 41 | - cmd: > 42 | docker build --rm 43 | --build-arg GIT_COMMIT={{.GIT_COMMIT}} 44 | -t {{.IMAGE}} . 45 | - cmd: > 46 | docker tag {{.IMAGE}} {{.IMAGE}}:{{.GIT_COMMIT}} 47 | 48 | swagger: 49 | cmds: 50 | - swag fmt 51 | - swag init -o docs/api 52 | 53 | cli_docs: 54 | cmds: 55 | - go run docs/cli/docs.go 56 | 57 | docs: 58 | cmds: 59 | - task: swagger 60 | - task: cli_docs 61 | -------------------------------------------------------------------------------- /api/api_suite_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestApi(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Api Suite") 13 | } 14 | -------------------------------------------------------------------------------- /api/logging.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/chi/v5/middleware" 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | func (router *router) Log(next http.Handler) http.Handler { 11 | fn := func(w http.ResponseWriter, r *http.Request) { 12 | ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) 13 | 14 | defer func() { 15 | log.Debug().Fields(map[string]any{ 16 | "remote_addr": r.RemoteAddr, 17 | "path": r.URL.Path, 18 | "proto": r.Proto, 19 | "method": r.Method, 20 | "host": r.Host, 21 | "user_agent": r.UserAgent(), 22 | "status_code": ww.Status(), 23 | }).Msg("api request") 24 | }() 25 | 26 | next.ServeHTTP(ww, r) 27 | } 28 | 29 | return http.HandlerFunc(fn) 30 | } 31 | -------------------------------------------------------------------------------- /api/logout.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/davidborzek/tvhgo/api/request" 8 | "github.com/davidborzek/tvhgo/api/response" 9 | ) 10 | 11 | // Internal implementation to delete the session cookie. 12 | func deleteSessionCookie(w http.ResponseWriter, cookieName string) { 13 | c := &http.Cookie{ 14 | Name: cookieName, 15 | Value: "", 16 | Path: "/api", 17 | MaxAge: 0, 18 | HttpOnly: true, 19 | } 20 | 21 | http.SetCookie(w, c) 22 | } 23 | 24 | func (s *router) Logout(w http.ResponseWriter, r *http.Request) { 25 | ctx, ok := request.GetAuthContext(r.Context()) 26 | if !ok { 27 | response.InternalErrorCommon(w) 28 | return 29 | } 30 | 31 | if ctx.SessionID == nil { 32 | response.Conflict(w, fmt.Errorf("cannot logout token")) 33 | return 34 | } 35 | 36 | err := s.sessionManager.Revoke( 37 | r.Context(), *ctx.SessionID, ctx.UserID, 38 | ) 39 | 40 | if err != nil { 41 | response.InternalError(w, err) 42 | return 43 | } 44 | 45 | deleteSessionCookie(w, s.cfg.Auth.Session.CookieName) 46 | w.WriteHeader(200) 47 | } 48 | -------------------------------------------------------------------------------- /api/picon.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/davidborzek/tvhgo/api/response" 9 | "github.com/davidborzek/tvhgo/core" 10 | "github.com/go-chi/chi/v5" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | // GetPicon godoc 15 | // 16 | // @Summary Get channel picon 17 | // @Tags picon 18 | // @Param id path string true "Picon id" 19 | // @Produce image/* 20 | // @Produce json 21 | // @Success 200 22 | // @Failure 401 {object} response.ErrorResponse 23 | // @Failure 404 {object} response.ErrorResponse 24 | // @Failure 500 {object} response.ErrorResponse 25 | // @Security JWT 26 | // @Router /picon/{id} [get] 27 | func (s *router) GetPicon(w http.ResponseWriter, r *http.Request) { 28 | id, err := strconv.Atoi(chi.URLParam(r, "id")) 29 | if err != nil { 30 | response.BadRequest(w, err) 31 | return 32 | } 33 | 34 | picon, err := s.picons.Get(r.Context(), id) 35 | if err != nil { 36 | if err == core.ErrPiconNotFound { 37 | response.NotFound(w, err) 38 | return 39 | } 40 | 41 | log.Error().Int("id", id). 42 | Err(err).Msg("failed to get picon") 43 | 44 | response.InternalErrorCommon(w) 45 | return 46 | } 47 | 48 | io.Copy(w, picon) 49 | } 50 | -------------------------------------------------------------------------------- /api/profile.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/davidborzek/tvhgo/api/response" 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | // GetStreamProfiles godoc 11 | // 12 | // @Summary Get list of stream profiles 13 | // @Tags profiles 14 | // 15 | // @Produce json 16 | // @Success 200 {array} core.StreamProfile 17 | // @Failure 401 {object} response.ErrorResponse 18 | // @Failure 500 {object} response.ErrorResponse 19 | // @Router /profiles/stream [get] 20 | func (s *router) GetStreamProfiles(w http.ResponseWriter, r *http.Request) { 21 | profiles, err := s.profileService.GetStreamProfiles(r.Context()) 22 | if err != nil { 23 | log.Error().Err(err).Msg("failed to get channels") 24 | 25 | response.InternalErrorCommon(w) 26 | return 27 | } 28 | 29 | response.JSON(w, profiles, 200) 30 | } 31 | -------------------------------------------------------------------------------- /api/request/context.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/davidborzek/tvhgo/core" 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | type key int 11 | 12 | const ( 13 | sessionKey key = iota 14 | ) 15 | 16 | // WithAuthContext returns a copy of parent ctx which includes the auth context. 17 | func WithAuthContext(parent context.Context, auth *core.AuthContext) context.Context { 18 | return context.WithValue(parent, sessionKey, auth) 19 | } 20 | 21 | // SessionFrom returns the auth context for the given context. 22 | func GetAuthContext(ctx context.Context) (*core.AuthContext, bool) { 23 | auth, ok := ctx.Value(sessionKey).(*core.AuthContext) 24 | if !ok { 25 | log.Error().Msg("auth context could not be obtained from request context") 26 | } 27 | 28 | return auth, ok 29 | } 30 | -------------------------------------------------------------------------------- /api/request/context_test.go: -------------------------------------------------------------------------------- 1 | package request_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/davidborzek/tvhgo/api/request" 8 | "github.com/davidborzek/tvhgo/core" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestWithAuthContextEntrichesAContextWithAuthContext(t *testing.T) { 13 | sessionId := int64(2345) 14 | 15 | ctx := context.Background() 16 | expectedAuthCtx := core.AuthContext{ 17 | UserID: 1234, 18 | SessionID: &sessionId, 19 | } 20 | 21 | newCtx := request.WithAuthContext(ctx, &expectedAuthCtx) 22 | 23 | authCtx, ok := request.GetAuthContext(newCtx) 24 | 25 | assert.Equal(t, expectedAuthCtx, *authCtx) 26 | assert.True(t, ok) 27 | } 28 | 29 | func TestGetAuthContextReturnsFalseWhenAuthContextCouldNotBeObtained(t *testing.T) { 30 | ctx := context.Background() 31 | authCtx, ok := request.GetAuthContext(ctx) 32 | 33 | assert.Nil(t, authCtx) 34 | assert.False(t, ok) 35 | } 36 | -------------------------------------------------------------------------------- /api/request/request.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/go-chi/chi/v5" 12 | "github.com/gorilla/schema" 13 | ) 14 | 15 | var ( 16 | ErrFailedToBindJSON = errors.New("failed to parse json body") 17 | ) 18 | 19 | // BindJSON decodes the body of a http request to the 20 | // provided struct. 21 | func BindJSON(r *http.Request, dst interface{}) error { 22 | err := json.NewDecoder(r.Body).Decode(dst) 23 | if err != nil { 24 | return ErrFailedToBindJSON 25 | } 26 | 27 | r.Body.Close() 28 | return nil 29 | } 30 | 31 | // BindQuery decodes the url query of a http request to the 32 | // provided struct. 33 | func BindQuery(r *http.Request, dst interface{}) error { 34 | d := schema.NewDecoder() 35 | d.IgnoreUnknownKeys(true) 36 | 37 | return d.Decode(dst, r.URL.Query()) 38 | } 39 | 40 | // RemoteAddr returns the "real" IP address. 41 | func RemoteAddr(r *http.Request) string { 42 | addr := r.Header.Get("X-Real-IP") 43 | 44 | if addr == "" { 45 | addr = strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0] 46 | addr = strings.TrimSpace(addr) 47 | } 48 | 49 | if addr != "" && net.ParseIP(addr) == nil { 50 | addr = "" 51 | } 52 | 53 | if addr == "" { 54 | addr, _, _ = net.SplitHostPort(r.RemoteAddr) 55 | } 56 | 57 | return addr 58 | } 59 | 60 | // NumericURLParam returns a url parameter as int64. 61 | func NumericURLParam(r *http.Request, name string) (int64, error) { 62 | return strconv.ParseInt(chi.URLParam(r, name), 10, 0) 63 | } 64 | -------------------------------------------------------------------------------- /api/response/response.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | ) 8 | 9 | // JSON writes the json-encoded data to the response 10 | // with a given status code. 11 | func JSON(w http.ResponseWriter, v interface{}, status int) error { 12 | enc := json.NewEncoder(w) 13 | enc.SetEscapeHTML(false) 14 | 15 | w.Header().Set("Content-Type", "application/json") 16 | w.WriteHeader(status) 17 | return enc.Encode(v) 18 | } 19 | 20 | // CopyResponse writes a http response to the response 21 | // by copying the header, status code and the body. 22 | func CopyResponse(dest http.ResponseWriter, src *http.Response) (written int64, err error) { 23 | defer src.Body.Close() 24 | copyHeaders(dest.Header(), src.Header) 25 | dest.WriteHeader(src.StatusCode) 26 | return io.Copy(dest, src.Body) 27 | } 28 | 29 | func copyHeaders(dst, src http.Header) { 30 | for k, vv := range src { 31 | for _, v := range vv { 32 | dst.Add(k, v) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /cmd/admin/admin.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "github.com/davidborzek/tvhgo/cmd/admin/user" 5 | "github.com/rs/zerolog" 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | var ( 10 | Cmd = &cli.Command{ 11 | Name: "admin", 12 | Usage: "Admin controls for the tvhgo server", 13 | Subcommands: []*cli.Command{ 14 | user.Cmd, 15 | }, 16 | Before: before, 17 | } 18 | ) 19 | 20 | func before(_c *cli.Context) error { 21 | zerolog.SetGlobalLevel(zerolog.Disabled) 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /cmd/admin/user/delete.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/davidborzek/tvhgo/cmd/common" 8 | "github.com/davidborzek/tvhgo/repository/user" 9 | "github.com/davidborzek/tvhgo/services/clock" 10 | "github.com/urfave/cli/v2" 11 | ) 12 | 13 | var deleteCmd = &cli.Command{ 14 | Name: "delete", 15 | Usage: "Deletes a user", 16 | Flags: []cli.Flag{ 17 | &cli.StringFlag{ 18 | Name: "username", 19 | Aliases: []string{"u"}, 20 | Usage: "Username of the new user", 21 | Required: true, 22 | }, 23 | }, 24 | Action: delete, 25 | } 26 | 27 | func delete(ctx *cli.Context) error { 28 | _, db := common.Init(ctx) 29 | userRepository := user.New(db, clock.NewClock()) 30 | 31 | user, err := userRepository.FindByUsername(ctx.Context, ctx.String("username")) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | if user == nil { 37 | return errors.New("user not found") 38 | } 39 | 40 | if err := userRepository.Delete(ctx.Context, user); err != nil { 41 | return err 42 | } 43 | 44 | fmt.Println("User successfully deleted.") 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /cmd/admin/user/list.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/davidborzek/tvhgo/cmd/common" 8 | "github.com/davidborzek/tvhgo/core" 9 | "github.com/davidborzek/tvhgo/repository/user" 10 | "github.com/davidborzek/tvhgo/services/clock" 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | var listCmd = &cli.Command{ 15 | Name: "list", 16 | Usage: "List users.", 17 | Action: list, 18 | } 19 | 20 | func list(ctx *cli.Context) error { 21 | _, db := common.Init(ctx) 22 | 23 | userRepository := user.New(db, clock.NewClock()) 24 | 25 | users, err := userRepository.Find(ctx.Context, core.UserQueryParams{}) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | if len(users.Entries) == 0 { 31 | fmt.Printf("No users found.") 32 | return nil 33 | } 34 | 35 | common.PrintTable( 36 | []string{"ID", "Username", "Email", "Name", "Created", "Updated"}, 37 | common.MapRows(users.Entries, func(user *core.User) []any { 38 | return []any{ 39 | user.ID, 40 | user.Username, 41 | user.Email, 42 | user.DisplayName, 43 | time.Unix(user.CreatedAt, 0).Format(time.RFC822), 44 | time.Unix(user.UpdatedAt, 0).Format(time.RFC822), 45 | } 46 | }), 47 | ) 48 | 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /cmd/admin/user/token/generate.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/davidborzek/tvhgo/cmd/common" 8 | "github.com/davidborzek/tvhgo/repository/token" 9 | "github.com/davidborzek/tvhgo/repository/user" 10 | "github.com/davidborzek/tvhgo/services/auth" 11 | "github.com/davidborzek/tvhgo/services/clock" 12 | "github.com/urfave/cli/v2" 13 | ) 14 | 15 | var generateCmd = &cli.Command{ 16 | Name: "generate", 17 | Usage: "Generate a new token", 18 | Flags: []cli.Flag{ 19 | &cli.StringFlag{ 20 | Name: "username", 21 | Aliases: []string{"u"}, 22 | Usage: "Username of the new user", 23 | Required: true, 24 | }, 25 | &cli.StringFlag{ 26 | Name: "name", 27 | Aliases: []string{"n"}, 28 | Usage: "Name of the token,", 29 | Required: true, 30 | }, 31 | }, 32 | Action: generate, 33 | } 34 | 35 | func generate(ctx *cli.Context) error { 36 | _, db := common.Init(ctx) 37 | userRepository := user.New(db, clock.NewClock()) 38 | 39 | user, err := userRepository.FindByUsername(ctx.Context, ctx.String("username")) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | if user == nil { 45 | return errors.New("user not found") 46 | } 47 | 48 | tokenRepository := token.New(db) 49 | tokenService := auth.NewTokenService(tokenRepository) 50 | 51 | tokenValue, err := tokenService.Create(ctx.Context, user.ID, ctx.String("name")) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | fmt.Printf("Token generated: %s\n", tokenValue) 57 | 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /cmd/admin/user/token/list.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/davidborzek/tvhgo/cmd/common" 9 | "github.com/davidborzek/tvhgo/core" 10 | "github.com/davidborzek/tvhgo/repository/token" 11 | "github.com/davidborzek/tvhgo/repository/user" 12 | "github.com/davidborzek/tvhgo/services/clock" 13 | "github.com/urfave/cli/v2" 14 | ) 15 | 16 | var listCmd = &cli.Command{ 17 | Name: "list", 18 | Usage: "List tokens of a user", 19 | Flags: []cli.Flag{ 20 | &cli.StringFlag{ 21 | Name: "username", 22 | Aliases: []string{"u"}, 23 | Usage: "Username of the new user", 24 | Required: true, 25 | }, 26 | }, 27 | Action: list, 28 | } 29 | 30 | func list(ctx *cli.Context) error { 31 | _, db := common.Init(ctx) 32 | userRepository := user.New(db, clock.NewClock()) 33 | 34 | user, err := userRepository.FindByUsername(ctx.Context, ctx.String("username")) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | if user == nil { 40 | return errors.New("user not found") 41 | } 42 | 43 | tokenRepository := token.New(db) 44 | tokens, err := tokenRepository.FindByUser(ctx.Context, user.ID) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | if len(tokens) == 0 { 50 | fmt.Printf("No tokens found.") 51 | return nil 52 | } 53 | 54 | common.PrintTable( 55 | []string{"ID", "Name", "Created"}, 56 | common.MapRows(tokens, func(token *core.Token) []any { 57 | return []any{ 58 | token.ID, 59 | token.Name, 60 | time.Unix(token.CreatedAt, 0).Format(time.RFC822), 61 | } 62 | }), 63 | ) 64 | 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /cmd/admin/user/token/revoke.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/davidborzek/tvhgo/cmd/common" 7 | "github.com/davidborzek/tvhgo/repository/token" 8 | "github.com/davidborzek/tvhgo/services/auth" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | var revokeCmd = &cli.Command{ 13 | Name: "revoke", 14 | Usage: "Revokes a token", 15 | Flags: []cli.Flag{ 16 | &cli.StringFlag{ 17 | Name: "id", 18 | Usage: "ID of the token,", 19 | Required: true, 20 | }, 21 | }, 22 | Action: revoke, 23 | } 24 | 25 | func revoke(ctx *cli.Context) error { 26 | _, db := common.Init(ctx) 27 | tokenRepository := token.New(db) 28 | tokenService := auth.NewTokenService(tokenRepository) 29 | 30 | if err := tokenService.Revoke(ctx.Context, ctx.Int64("id")); err != nil { 31 | return err 32 | } 33 | 34 | fmt.Println("Token successfully revoked.") 35 | 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /cmd/admin/user/token/token.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | "github.com/urfave/cli/v2" 5 | ) 6 | 7 | var Cmd = &cli.Command{ 8 | Name: "token", 9 | Usage: "Manage tokens of a user", 10 | Subcommands: []*cli.Command{ 11 | listCmd, 12 | generateCmd, 13 | revokeCmd, 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /cmd/admin/user/twofa/disable.go: -------------------------------------------------------------------------------- 1 | package twofa 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/davidborzek/tvhgo/cmd/common" 8 | "github.com/davidborzek/tvhgo/core" 9 | twofactorsettings "github.com/davidborzek/tvhgo/repository/two_factor_settings" 10 | "github.com/davidborzek/tvhgo/repository/user" 11 | "github.com/davidborzek/tvhgo/services/clock" 12 | "github.com/urfave/cli/v2" 13 | ) 14 | 15 | var disableCmd = &cli.Command{ 16 | Name: "disable", 17 | Usage: "Disable 2FA for a user.", 18 | Flags: []cli.Flag{ 19 | &cli.StringFlag{ 20 | Name: "username", 21 | Aliases: []string{"u"}, 22 | Usage: "Username of the new user", 23 | Required: true, 24 | }, 25 | }, 26 | Action: disable, 27 | } 28 | 29 | func disable(ctx *cli.Context) error { 30 | _, db := common.Init(ctx) 31 | userRepository := user.New(db, clock.NewClock()) 32 | 33 | user, err := userRepository.FindByUsername(ctx.Context, ctx.String("username")) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | if user == nil { 39 | return errors.New("user not found") 40 | } 41 | 42 | twoFactorSettingsRepository := twofactorsettings.New(db) 43 | 44 | err = twoFactorSettingsRepository.Delete(ctx.Context, &core.TwoFactorSettings{ 45 | UserID: user.ID, 46 | }) 47 | 48 | if err != nil { 49 | return err 50 | } 51 | 52 | fmt.Println("Two factor auth successfully disabled.") 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /cmd/admin/user/twofa/twofa.go: -------------------------------------------------------------------------------- 1 | package twofa 2 | 3 | import "github.com/urfave/cli/v2" 4 | 5 | var Cmd = &cli.Command{ 6 | Name: "2fa", 7 | Usage: "Manage 2FA of a user", 8 | Subcommands: []*cli.Command{ 9 | disableCmd, 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /cmd/admin/user/user.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "github.com/davidborzek/tvhgo/cmd/admin/user/token" 5 | "github.com/davidborzek/tvhgo/cmd/admin/user/twofa" 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | var ( 10 | Cmd = &cli.Command{ 11 | Name: "user", 12 | Usage: "Manage users of the tvhgo server", 13 | Subcommands: []*cli.Command{ 14 | addCmd, 15 | listCmd, 16 | deleteCmd, 17 | twofa.Cmd, 18 | token.Cmd, 19 | }, 20 | } 21 | ) 22 | -------------------------------------------------------------------------------- /cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/davidborzek/tvhgo/cmd/admin" 5 | "github.com/davidborzek/tvhgo/cmd/server" 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | var App = cli.App{ 10 | Name: "tvhgo", 11 | Usage: "Modern and secure api and web interface for Tvheadend", 12 | Commands: []*cli.Command{ 13 | server.Cmd, 14 | admin.Cmd, 15 | }, 16 | Flags: []cli.Flag{ 17 | &cli.StringFlag{ 18 | Name: "config", 19 | Aliases: []string{"c"}, 20 | Usage: "Path to the configuration file", 21 | EnvVars: []string{"TVHGO_CONFIG"}, 22 | }, 23 | }, 24 | DefaultCommand: "server", 25 | } 26 | -------------------------------------------------------------------------------- /cmd/common/common.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "text/tabwriter" 8 | 9 | "github.com/davidborzek/tvhgo/config" 10 | "github.com/davidborzek/tvhgo/db" 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | func PrintTable(headers []string, rows [][]any) { 15 | w := tabwriter.NewWriter(os.Stdout, 10, 0, 1, ' ', 0) 16 | 17 | for i, h := range headers { 18 | fmt.Fprintf(w, "%s", h) 19 | 20 | if i < len(headers) { 21 | fmt.Fprint(w, "\t") 22 | } 23 | } 24 | fmt.Fprint(w, "\n") 25 | 26 | for _, row := range rows { 27 | for i, c := range row { 28 | fmt.Fprintf(w, "%v", c) 29 | 30 | if i < len(row) { 31 | fmt.Fprint(w, "\t") 32 | } 33 | } 34 | 35 | fmt.Fprint(w, "\n") 36 | } 37 | 38 | w.Flush() 39 | } 40 | 41 | func MapRows[T any](values []T, mapper func(t T) []any) [][]any { 42 | rows := make([][]any, 0) 43 | for _, v := range values { 44 | rows = append(rows, mapper(v)) 45 | } 46 | return rows 47 | } 48 | 49 | func Init(ctx *cli.Context) (*config.Config, *db.DB) { 50 | cfg, err := config.Load(ctx.String("config")) 51 | if err != nil { 52 | log.Println("failed to load config:", err) 53 | os.Exit(1) 54 | } 55 | 56 | dbConn, err := db.Connect(cfg.Database) 57 | if err != nil { 58 | log.Println("failed create db connection:", err) 59 | os.Exit(1) 60 | } 61 | 62 | return cfg, dbConn 63 | } 64 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:17 4 | restart: always 5 | environment: 6 | POSTGRES_USER: tvhgo 7 | POSTGRES_PASSWORD: supersecret 8 | POSTGRES_DB: tvhgo 9 | ports: 10 | - "5432:5432" 11 | -------------------------------------------------------------------------------- /config.example.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | server: 3 | host: 127.0.0.1 4 | port: 8080 5 | 6 | log: 7 | level: info 8 | format: console 9 | 10 | tvheadend: 11 | scheme: http 12 | host: 13 | port: 9981 14 | username: 15 | password: 16 | 17 | database: 18 | path: ./tvhgo.db 19 | 20 | auth: 21 | session: 22 | cookie_name: tvhgo_session 23 | cookie_secure: false 24 | maximum_inactive_lifetime: 168h 25 | maximum_lifetime: 720h 26 | token_rotation_interval: 30m 27 | cleanup_interval: 12h 28 | 29 | totp: 30 | issuer: tvhgo 31 | 32 | reverse_proxy: 33 | enabled: false 34 | user_header: Remote-User 35 | email_header: Remote-Email 36 | name_header: Remote-Name 37 | allowed_proxies: ["127.0.0.0/24", "127.0.0.1"] 38 | allow_registration: false 39 | 40 | metrics: 41 | enabled: true 42 | path: /metrics 43 | port: 8081 44 | host: 127.0.0.1 45 | token: 46 | -------------------------------------------------------------------------------- /config/logging.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "strings" 7 | "time" 8 | 9 | "github.com/rs/zerolog" 10 | "github.com/rs/zerolog/log" 11 | ) 12 | 13 | type ( 14 | LogConfig struct { 15 | Level string `yaml:"level" env:"LEVEL"` 16 | Format string `yaml:"format" env:"FORMAT"` 17 | } 18 | ) 19 | 20 | func (c *LogConfig) SetDefaults() { 21 | if c.Level == "" { 22 | c.Level = "info" 23 | } 24 | 25 | if c.Format == "" { 26 | c.Format = "console" 27 | } 28 | } 29 | 30 | func (c *LogConfig) SetupLogger() { 31 | zerolog.SetGlobalLevel(parseLogLevel(c.Level)) 32 | 33 | if c.Format == "json" { 34 | initLogger(os.Stdout) 35 | } else if c.Format != "console" { 36 | log.Warn().Str("format", c.Format). 37 | Msg("invalid log format provided - falling back to 'console'") 38 | } 39 | } 40 | 41 | func parseLogLevel(level string) zerolog.Level { 42 | switch strings.ToLower(level) { 43 | case "debug": 44 | return zerolog.DebugLevel 45 | case "info": 46 | return zerolog.InfoLevel 47 | case "warning": 48 | return zerolog.WarnLevel 49 | case "error": 50 | return zerolog.ErrorLevel 51 | case "fatal": 52 | return zerolog.FatalLevel 53 | } 54 | 55 | log.Warn().Str("level", level). 56 | Msg("invalid log level provided - falling back to 'info'") 57 | 58 | return zerolog.InfoLevel 59 | } 60 | 61 | func initLogger(writer io.Writer) { 62 | log.Logger = zerolog.New(writer). 63 | With().Timestamp().Stack().Caller().Logger() 64 | } 65 | 66 | func InitDefaultLogger() { 67 | initLogger(zerolog.ConsoleWriter{ 68 | Out: os.Stdout, 69 | TimeFormat: time.RFC3339, 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /config/metrics.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "net" 5 | "strconv" 6 | ) 7 | 8 | const ( 9 | defaultMetricsPath = "/metrics" 10 | defaultMetricsPort = 8081 11 | ) 12 | 13 | type ( 14 | MetricsConfig struct { 15 | Enabled bool `yaml:"enabled" env:"ENABLED"` 16 | Path string `yaml:"path" env:"PATH"` 17 | Port int `yaml:"port" env:"PORT"` 18 | Host string `yaml:"host" env:"HOST"` 19 | Token string `yaml:"token" env:"TOKEN"` 20 | } 21 | ) 22 | 23 | func (c *MetricsConfig) SetDefaults() { 24 | if c.Path == "" { 25 | c.Path = defaultMetricsPath 26 | } 27 | 28 | if c.Port == 0 { 29 | c.Port = defaultMetricsPort 30 | } 31 | } 32 | 33 | func (c *MetricsConfig) Addr() string { 34 | return net.JoinHostPort( 35 | c.Host, 36 | strconv.Itoa(c.Port), 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /config/server.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "net" 5 | "strconv" 6 | ) 7 | 8 | const ( 9 | defaultServerPort = 8080 10 | ) 11 | 12 | type ( 13 | SwaggerUIConfig struct { 14 | Enabled *bool `yaml:"enabled" env:"ENABLED"` 15 | } 16 | 17 | ServerConfig struct { 18 | Host string `yaml:"host" env:"HOST"` 19 | Port int `yaml:"port" env:"PORT"` 20 | SwaggerUI SwaggerUIConfig `yaml:"swagger_ui" env:"SWAGGER_UI"` 21 | } 22 | ) 23 | 24 | func (c *ServerConfig) SetDefaults() { 25 | if c.Port == 0 { 26 | c.Port = defaultServerPort 27 | } 28 | 29 | if c.SwaggerUI.Enabled == nil { 30 | v := true 31 | c.SwaggerUI.Enabled = &v 32 | } 33 | } 34 | 35 | func (c *ServerConfig) Addr() string { 36 | return net.JoinHostPort( 37 | c.Host, 38 | strconv.Itoa(c.Port), 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /config/tvheadend.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "net/url" 7 | "strconv" 8 | ) 9 | 10 | const ( 11 | defaultTvheadendScheme = "http" 12 | defaultTvheadendPort = 9981 13 | ) 14 | 15 | type ( 16 | TvheadendConfig struct { 17 | Scheme string `yaml:"scheme" env:"SCHEME"` 18 | Host string `yaml:"host" env:"HOST"` 19 | Port int `yaml:"port" env:"PORT"` 20 | Username string `yaml:"username" env:"USERNAME"` 21 | Password string `yaml:"password" env:"PASSWORD,unset"` 22 | } 23 | ) 24 | 25 | func (c *TvheadendConfig) Validate() error { 26 | if c.Host == "" { 27 | return errors.New("tvheadend host is not set") 28 | } 29 | 30 | return nil 31 | } 32 | 33 | func (c *TvheadendConfig) SetDefaults() { 34 | if c.Scheme == "" { 35 | c.Scheme = defaultTvheadendScheme 36 | } 37 | if c.Port == 0 { 38 | c.Port = defaultTvheadendPort 39 | } 40 | } 41 | 42 | func (c *TvheadendConfig) URL() string { 43 | tvhUrl := url.URL{ 44 | Scheme: c.Scheme, 45 | Host: net.JoinHostPort( 46 | c.Host, 47 | strconv.Itoa(c.Port), 48 | ), 49 | } 50 | return tvhUrl.String() 51 | } 52 | -------------------------------------------------------------------------------- /core/channel.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/davidborzek/tvhgo/conv" 10 | "github.com/davidborzek/tvhgo/tvheadend" 11 | ) 12 | 13 | var ( 14 | ErrChannelNotFound = errors.New("channel not found") 15 | ) 16 | 17 | type ( 18 | // Channel defines a channel in tvheadend. 19 | Channel struct { 20 | ID string `json:"id"` 21 | Enabled bool `json:"enabled"` 22 | Name string `json:"name"` 23 | Number int `json:"number"` 24 | PiconID int `json:"piconId"` 25 | } 26 | 27 | // ChannelService provides access to channel 28 | // resources from the tvheadend server. 29 | ChannelService interface { 30 | // GetAll returns a list of channels. 31 | GetAll(ctx context.Context, params PaginationSortQueryParams) ([]*Channel, error) 32 | // Get returns a channel by id. 33 | Get(ctx context.Context, id string) (*Channel, error) 34 | } 35 | ) 36 | 37 | func MapTvheadendIconUrlToPiconID(iconUrl string) int { 38 | split := strings.Split(iconUrl, "/") 39 | 40 | var piconID int 41 | if len(split) == 2 { 42 | piconID, _ = strconv.Atoi(split[1]) 43 | } 44 | 45 | return piconID 46 | } 47 | 48 | // MapTvheadendIdnodeToChannel maps a tvheadend.Idnode to a Channel. 49 | func MapTvheadendIdnodeToChannel(idnode tvheadend.Idnode) (*Channel, error) { 50 | r := Channel{ 51 | ID: idnode.UUID, 52 | } 53 | 54 | for _, p := range idnode.Params { 55 | var err error 56 | 57 | switch p.ID { 58 | case "enabled": 59 | r.Enabled, err = conv.InterfaceToBool(p.Value) 60 | case "name": 61 | r.Name, err = conv.InterfaceToString(p.Value) 62 | case "number": 63 | r.Number, err = conv.InterfaceToInt(p.Value) 64 | case "icon_public_url": 65 | var value string 66 | value, err = conv.InterfaceToString(p.Value) 67 | r.PiconID = MapTvheadendIconUrlToPiconID(value) 68 | } 69 | 70 | if err != nil { 71 | return nil, err 72 | } 73 | } 74 | 75 | return &r, nil 76 | } 77 | -------------------------------------------------------------------------------- /core/channel_test.go: -------------------------------------------------------------------------------- 1 | package core_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/davidborzek/tvhgo/conv" 7 | "github.com/davidborzek/tvhgo/core" 8 | "github.com/davidborzek/tvhgo/tvheadend" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestMapTvheadendIconUrlToPiconID(t *testing.T) { 13 | piconId := core.MapTvheadendIconUrlToPiconID("imagecache/223") 14 | assert.Equal(t, 223, piconId) 15 | } 16 | 17 | func TestMapTvheadendIconUrlToPiconIDReturnsZeroOnError(t *testing.T) { 18 | piconId := core.MapTvheadendIconUrlToPiconID("erroneousUrl") 19 | assert.Equal(t, 0, piconId) 20 | } 21 | 22 | func TestMapTvheadendIdnodeToChannel(t *testing.T) { 23 | enabled := true 24 | name := "someName" 25 | number := 123 26 | iconUrl := "imagecache/223" 27 | 28 | idnode := tvheadend.Idnode{ 29 | UUID: "someID", 30 | Params: []tvheadend.InodeParams{ 31 | { 32 | ID: "enabled", 33 | Value: enabled, 34 | }, 35 | { 36 | ID: "name", 37 | Value: name, 38 | }, 39 | { 40 | ID: "number", 41 | Value: float64(number), 42 | }, 43 | { 44 | ID: "icon_public_url", 45 | Value: iconUrl, 46 | }, 47 | }, 48 | } 49 | 50 | channel, err := core.MapTvheadendIdnodeToChannel(idnode) 51 | 52 | assert.Nil(t, err) 53 | assert.Equal(t, enabled, channel.Enabled) 54 | assert.Equal(t, name, channel.Name) 55 | assert.Equal(t, number, channel.Number) 56 | assert.Equal(t, 223, channel.PiconID) 57 | } 58 | 59 | func TestMapTvheadendIdnodeToChannelFailsForUnexpectedType(t *testing.T) { 60 | idnode := tvheadend.Idnode{ 61 | UUID: "someID", 62 | Params: []tvheadend.InodeParams{ 63 | { 64 | ID: "enabled", 65 | Value: "true", 66 | }, 67 | }, 68 | } 69 | 70 | channel, err := core.MapTvheadendIdnodeToChannel(idnode) 71 | 72 | assert.Nil(t, channel) 73 | assert.Equal(t, conv.ErrInterfaceToBool, err) 74 | } 75 | -------------------------------------------------------------------------------- /core/clock.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import "time" 4 | 5 | type Clock interface { 6 | Now() time.Time 7 | } 8 | -------------------------------------------------------------------------------- /core/common.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | type ( 4 | // ListResult defines a generic result of multiple entries 5 | // combined with the number of total results and the offset. 6 | ListResult[T any] struct { 7 | Entries []T `json:"entries"` 8 | Total int64 `json:"total"` 9 | Offset int64 `json:"offset"` 10 | } 11 | ) 12 | -------------------------------------------------------------------------------- /core/errors.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrUnexpectedError = errors.New("unexpected error") 7 | ) 8 | -------------------------------------------------------------------------------- /core/picon.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | ) 8 | 9 | var ( 10 | ErrPiconNotFound = errors.New("picon not found") 11 | ) 12 | 13 | type ( 14 | PiconService interface { 15 | // GetPicon returns the picon of a channel. 16 | Get(ctx context.Context, id int) (io.Reader, error) 17 | } 18 | ) 19 | -------------------------------------------------------------------------------- /core/profile.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/davidborzek/tvhgo/tvheadend" 7 | ) 8 | 9 | type ( 10 | StreamProfile struct { 11 | ID string `json:"id"` 12 | Name string `json:"name"` 13 | } 14 | 15 | ProfileService interface { 16 | GetStreamProfiles(ctx context.Context) ([]StreamProfile, error) 17 | } 18 | ) 19 | 20 | func NewStreamProfile(profile tvheadend.StreamProfile) StreamProfile { 21 | return StreamProfile{ 22 | ID: profile.Key, 23 | Name: profile.Val, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /core/session.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type ( 8 | // Session defines a representation for a session of a user. 9 | Session struct { 10 | ID int64 `json:"id"` 11 | UserId int64 `json:"userId"` 12 | HashedToken string `json:"-"` 13 | ClientIP string `json:"clientIp"` 14 | UserAgent string `json:"userAgent"` 15 | CreatedAt int64 `json:"createdAt"` 16 | LastUsedAt int64 `json:"lastUsedAt"` 17 | RotatedAt int64 `json:"-"` 18 | } 19 | 20 | // SessionRepository defines CRUD operations for working with sessions. 21 | SessionRepository interface { 22 | // Find returns a sessions. 23 | Find(ctx context.Context, hashedToken string) (*Session, error) 24 | 25 | // FindByUser returns a list of sessions for a user. 26 | FindByUser(ctx context.Context, userID int64) ([]*Session, error) 27 | 28 | // Create persists a new session. 29 | Create(ctx context.Context, session *Session) error 30 | 31 | // Create persists a updated session. 32 | Update(ctx context.Context, session *Session) error 33 | 34 | // Delete deletes a session. 35 | Delete(ctx context.Context, sessionID int64, userID int64) error 36 | 37 | // DeleteExpired deletes all expired sessions. 38 | DeleteExpired( 39 | ctx context.Context, 40 | expirationDate int64, 41 | inactiveExpirationDate int64, 42 | ) (int64, error) 43 | } 44 | ) 45 | -------------------------------------------------------------------------------- /core/streaming.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | type ( 9 | StreamingService interface { 10 | // GetChannelStream returns a raw http response of the channel stream. 11 | GetChannelStream( 12 | ctx context.Context, 13 | channelNumber int64, 14 | profile string, 15 | ) (*http.Response, error) 16 | 17 | // GetRecordingStream returns a raw http response of the recording stream. 18 | GetRecordingStream(ctx context.Context, recordingId string) (*http.Response, error) 19 | } 20 | ) 21 | -------------------------------------------------------------------------------- /core/token.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import "context" 4 | 5 | type ( 6 | Token struct { 7 | ID int64 `json:"id"` 8 | UserID int64 `json:"-"` 9 | Name string `json:"name"` 10 | HashedToken string `json:"-"` 11 | CreatedAt int64 `json:"createdAt"` 12 | UpdatedAt int64 `json:"updatedAt"` 13 | } 14 | 15 | // TokenRepository defines CRUD operations working with Tokens. 16 | TokenRepository interface { 17 | // FindByToken returns a Token by a the hashed token. 18 | FindByToken(ctx context.Context, token string) (*Token, error) 19 | 20 | // FindByUser returns all token for a user. 21 | FindByUser(ctx context.Context, userID int64) ([]*Token, error) 22 | 23 | // Create persists a new Token. 24 | Create(ctx context.Context, token *Token) error 25 | 26 | // Delete deletes a Token. 27 | Delete(ctx context.Context, token *Token) error 28 | } 29 | ) 30 | -------------------------------------------------------------------------------- /core/two_factor_settings.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import "context" 4 | 5 | type ( 6 | // TwoFactorSettings defines the two factor settings of a user. 7 | TwoFactorSettings struct { 8 | UserID int64 `json:"-"` 9 | Secret string `json:"-"` 10 | Enabled bool `json:"enabled"` 11 | CreatedAt int64 `json:"-"` 12 | UpdatedAt int64 `json:"-"` 13 | } 14 | 15 | // TwoFactorSettingsRepository defines CRUD operations working with TwoFactorSettings. 16 | TwoFactorSettingsRepository interface { 17 | // Find returns two factor settings by a user id. 18 | Find(ctx context.Context, userID int64) (*TwoFactorSettings, error) 19 | 20 | // Create persists new two factor settings. 21 | Create(ctx context.Context, settings *TwoFactorSettings) error 22 | 23 | // Delete deletes two factor settings. 24 | Delete(ctx context.Context, settings *TwoFactorSettings) error 25 | 26 | Update(ctx context.Context, settings *TwoFactorSettings) error 27 | 28 | Save(ctx context.Context, settings *TwoFactorSettings) error 29 | } 30 | ) 31 | -------------------------------------------------------------------------------- /core/user.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | ) 7 | 8 | var ( 9 | ErrUsernameAlreadyExists = errors.New("username already exists") 10 | ErrEmailAlreadyExists = errors.New("email already exists") 11 | ) 12 | 13 | type ( 14 | // User represents a user. 15 | User struct { 16 | ID int64 `json:"id"` 17 | Username string `json:"username"` 18 | // PasswordHash hash of the users password 19 | PasswordHash string `json:"-"` 20 | Email string `json:"email"` 21 | DisplayName string `json:"displayName"` 22 | TwoFactor bool `json:"twoFactor"` 23 | IsAdmin bool `json:"isAdmin"` 24 | CreatedAt int64 `json:"createdAt"` 25 | UpdatedAt int64 `json:"updatedAt"` 26 | } 27 | 28 | UserListResult ListResult[*User] 29 | 30 | // UserQueryParams defines user query parameters. 31 | UserQueryParams struct { 32 | // (Optional) Limit the result. 33 | Limit int64 34 | // (Optional) Offset the result. 35 | // Can only be used together with Limit. 36 | Offset int64 37 | } 38 | 39 | UserRepository interface { 40 | // FindById returns a user by id. 41 | FindById(ctx context.Context, id int64) (*User, error) 42 | 43 | // FindByUsername returns a user by username. 44 | FindByUsername(ctx context.Context, user string) (*User, error) 45 | 46 | // Find returns a list of users paginated by UserQueryParams. 47 | Find(ctx context.Context, params UserQueryParams) (*UserListResult, error) 48 | 49 | // Create persists a new user. 50 | Create(ctx context.Context, user *User) error 51 | 52 | // Update persists an updated user. 53 | Update(ctx context.Context, user *User) error 54 | 55 | // Delete deletes a user. 56 | Delete(ctx context.Context, user *User) error 57 | } 58 | ) 59 | -------------------------------------------------------------------------------- /db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/davidborzek/tvhgo/config" 7 | "github.com/davidborzek/tvhgo/db/migration" 8 | ) 9 | 10 | type DB struct { 11 | *sql.DB 12 | Type config.DatabaseType 13 | } 14 | 15 | // Connect connects to a database and migrates schema. 16 | func Connect(cfg config.DatabaseConfig) (*DB, error) { 17 | driver := string(cfg.Type) 18 | dsn, err := cfg.DSN() 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | pool, err := sql.Open(driver, dsn) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | if err := migration.Migrate(driver, pool); err != nil { 29 | return nil, err 30 | } 31 | 32 | return &DB{ 33 | DB: pool, 34 | Type: cfg.Type, 35 | }, nil 36 | } 37 | -------------------------------------------------------------------------------- /db/migration/migration.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "database/sql" 5 | "embed" 6 | "fmt" 7 | 8 | "github.com/golang-migrate/migrate/v4" 9 | "github.com/golang-migrate/migrate/v4/database" 10 | "github.com/golang-migrate/migrate/v4/database/postgres" 11 | "github.com/golang-migrate/migrate/v4/database/sqlite3" 12 | "github.com/golang-migrate/migrate/v4/source/iofs" 13 | ) 14 | 15 | //go:embed sqlite3/*.sql 16 | var sqliteFs embed.FS 17 | 18 | //go:embed postgres/*.sql 19 | var postgresFs embed.FS 20 | 21 | // Migrate runs the SQL migration provided by the sql files in the `files` directory. 22 | func Migrate(driverType string, db *sql.DB) error { 23 | migrator, err := getMigrator(driverType, db) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | if err := migrator.Up(); err != nil && err != migrate.ErrNoChange { 29 | return err 30 | } 31 | 32 | return nil 33 | } 34 | 35 | func getMigrator(driverType string, db *sql.DB) (*migrate.Migrate, error) { 36 | 37 | var ( 38 | driver database.Driver 39 | err error 40 | fs embed.FS 41 | ) 42 | 43 | switch driverType { 44 | case "sqlite3": 45 | driver, err = sqlite3.WithInstance(db, &sqlite3.Config{}) 46 | if err != nil { 47 | return nil, err 48 | } 49 | fs = sqliteFs 50 | case "postgres": 51 | driver, err = postgres.WithInstance(db, &postgres.Config{}) 52 | if err != nil { 53 | return nil, err 54 | } 55 | fs = postgresFs 56 | default: 57 | return nil, fmt.Errorf("unsupported driver: %s", driverType) 58 | } 59 | 60 | source, err := iofs.New(fs, driverType) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | return migrate.NewWithInstance("iofs", source, driverType, driver) 66 | } 67 | -------------------------------------------------------------------------------- /db/migration/postgres/001_create_table_user.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS "user"; 2 | -------------------------------------------------------------------------------- /db/migration/postgres/001_create_table_user.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "user" ( 2 | id SERIAL PRIMARY KEY, 3 | username TEXT UNIQUE NOT NULL, 4 | password_hash TEXT NOT NULL, 5 | email TEXT UNIQUE NOT NULL, 6 | display_name TEXT NOT NULL, 7 | created_at INTEGER, 8 | updated_at INTEGER 9 | ); 10 | -------------------------------------------------------------------------------- /db/migration/postgres/002_create_table_session.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS "session"; 2 | -------------------------------------------------------------------------------- /db/migration/postgres/002_create_table_session.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "session" ( 2 | id SERIAL PRIMARY KEY, 3 | user_id INTEGER NOT NULL, 4 | hashed_token TEXT UNIQUE NOT NULL, 5 | client_ip TEXT NOT NULL, 6 | user_agent TEXT NOT NULL, 7 | created_at INTEGER NOT NULL, 8 | last_used_at INTEGER, 9 | rotated_at INTEGER NOT NULL, 10 | CONSTRAINT fk_user FOREIGN KEY(user_id) REFERENCES "user"(id) ON DELETE CASCADE 11 | ) 12 | -------------------------------------------------------------------------------- /db/migration/postgres/004_create_table_two_factor_settings.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS two_factor_settings; 2 | -------------------------------------------------------------------------------- /db/migration/postgres/004_create_table_two_factor_settings.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS two_factor_settings ( 2 | user_id INTEGER PRIMARY KEY, 3 | secret TEXT NOT NULL, 4 | enabled BOOLEAN NOT NULL, 5 | created_at INTEGER NOT NULL, 6 | updated_at INTEGER NOT NULL, 7 | CONSTRAINT fk_user FOREIGN KEY(user_id) REFERENCES "user"(id) ON DELETE CASCADE 8 | ); 9 | -------------------------------------------------------------------------------- /db/migration/postgres/005_create_table_token.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS token; 2 | -------------------------------------------------------------------------------- /db/migration/postgres/005_create_table_token.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS token ( 2 | id SERIAL PRIMARY KEY, 3 | user_id INTEGER, 4 | name TEXT NOT NULL, 5 | hashed_token TEXT UNIQUE NOT NULL, 6 | created_at INTEGER NOT NULL, 7 | updated_at INTEGER NOT NULL, 8 | CONSTRAINT fk_user FOREIGN KEY(user_id) REFERENCES "user"(id) ON DELETE CASCADE 9 | ); 10 | -------------------------------------------------------------------------------- /db/migration/postgres/006_add_admin_column.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "user" 2 | DROP COLUMN is_admin; -------------------------------------------------------------------------------- /db/migration/postgres/006_add_admin_column.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "user" 2 | ADD COLUMN is_admin BOOLEAN DEFAULT false; 3 | 4 | UPDATE "user" 5 | SET 6 | is_admin = true; 7 | -------------------------------------------------------------------------------- /db/migration/sqlite3/001_create_table_user.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS user; 2 | -------------------------------------------------------------------------------- /db/migration/sqlite3/001_create_table_user.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS user ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | username TEXT UNIQUE NOT NULL, 4 | password_hash TEXT NOT NULL, 5 | email TEXT UNIQUE NOT NULL, 6 | display_name TEXT NOT NULL, 7 | created_at INTEGER, 8 | updated_at INTEGER 9 | ); 10 | -------------------------------------------------------------------------------- /db/migration/sqlite3/002_create_table_session.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS session; 2 | -------------------------------------------------------------------------------- /db/migration/sqlite3/002_create_table_session.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS session ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | user_id INTEGER NOT NULL, 4 | hashed_token TEXT UNIQUE NOT NULL, 5 | client_ip TEXT NOT NULL, 6 | user_agent TEXT NOT NULL, 7 | created_at INTEGER NOT NULL, 8 | last_used_at INTEGER, 9 | rotated_at INTEGER NOT NULL, 10 | FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE 11 | ) 12 | -------------------------------------------------------------------------------- /db/migration/sqlite3/004_create_table_two_factor_settings.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS two_factor_settings; 2 | -------------------------------------------------------------------------------- /db/migration/sqlite3/004_create_table_two_factor_settings.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS two_factor_settings ( 2 | user_id INTEGER PRIMARY KEY, 3 | secret TEXT NOT NULL, 4 | enabled BOOLEAN NOT NULL, 5 | created_at INTEGER NOT NULL, 6 | updated_at INTEGER NOT NULL, 7 | FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE 8 | ); 9 | -------------------------------------------------------------------------------- /db/migration/sqlite3/005_create_table_token.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS two_factor_settings; 2 | -------------------------------------------------------------------------------- /db/migration/sqlite3/005_create_table_token.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS token ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | user_id INTEGER, 4 | name TEXT NOT NULL, 5 | hashed_token TEXT UNIQUE NOT NULL, 6 | created_at INTEGER NOT NULL, 7 | updated_at INTEGER NOT NULL, 8 | FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE 9 | ); 10 | -------------------------------------------------------------------------------- /db/migration/sqlite3/006_add_admin_column.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE user 2 | DROP COLUMN is_admin; -------------------------------------------------------------------------------- /db/migration/sqlite3/006_add_admin_column.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE user 2 | ADD COLUMN is_admin BOOLEAN DEFAULT FALSE; 3 | 4 | UPDATE user 5 | SET 6 | is_admin = TRUE; -------------------------------------------------------------------------------- /db/testdb/testdb.go: -------------------------------------------------------------------------------- 1 | //go:build !prod 2 | // +build !prod 3 | 4 | package testdb 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/davidborzek/tvhgo/config" 10 | "github.com/davidborzek/tvhgo/db" 11 | ) 12 | 13 | // Setup configures a in-memory sqlite3 test database. 14 | func Setup() (*db.DB, error) { 15 | return db.Connect(config.DatabaseConfig{ 16 | Type: config.DatabaseTypeSqlite, 17 | Path: ":memory:", 18 | }) 19 | } 20 | 21 | // Close closes the database connection. 22 | func Close(db *db.DB) { 23 | db.Close() 24 | } 25 | 26 | // TruncateTables truncates the given tables. 27 | func TruncateTables(db *db.DB, tables ...string) error { 28 | for _, table := range tables { 29 | _, err := db.Exec(fmt.Sprintf("DELETE FROM %s", table)) 30 | if err != nil { 31 | return err 32 | } 33 | } 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /docs/auth.md: -------------------------------------------------------------------------------- 1 | ## Reverse proxy auth 2 | 3 | You configure tvhgo to let a HTTP reverse proxy handle the authentication. 4 | 5 | ```yaml 6 | auth: 7 | reverse_proxy: 8 | # Defaults to false, set to true to enable reverse proxy authentication. 9 | enabled: true 10 | # HTTP header containing the username. 11 | user_header: X-MY-USER_HEADER 12 | # HTTP header containing the email. (not required) 13 | # If not available the username will be used. 14 | email_header: X-MY-EMAIL_HEADER 15 | # HTTP header containing the name. (not required) 16 | # If not available the username will be used. 17 | name_header: X-MY-NAME_HEADER 18 | # Limit where the reverse proxy is allowed to come from to prevent 19 | # spoofing the headers. If not set, all requests will be blocked. 20 | allowed_proxies: ["192.168.1.1", "192.168.2.0/24"] 21 | # If this is enabled, not existing users will automatically be registered. 22 | allow_registration: true 23 | ``` 24 | 25 | See [Reverse proxy auth](configuration.md/#reverse-proxy-auth-config-authreverse_proxy) for further information. 26 | -------------------------------------------------------------------------------- /docs/cli/docs.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/davidborzek/tvhgo/cmd" 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | func main() { 11 | md, err := cmd.App.ToMarkdown() 12 | if err != nil { 13 | log.Fatal().Err(err).Msg("failed to generate cli markdown docs") 14 | } 15 | 16 | if err := os.WriteFile("./docs/cli/docs.md", []byte(md), 0655); err != nil { 17 | log.Fatal().Err(err).Msg("failed to generate cli markdown docs") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /docs/cli/docs.md: -------------------------------------------------------------------------------- 1 | # NAME 2 | 3 | tvhgo - Modern and secure api and web interface for Tvheadend 4 | 5 | # SYNOPSIS 6 | 7 | tvhgo 8 | 9 | ``` 10 | [--config|-c]=[value] 11 | ``` 12 | 13 | **Usage**: 14 | 15 | ``` 16 | tvhgo [GLOBAL OPTIONS] command [COMMAND OPTIONS] [ARGUMENTS...] 17 | ``` 18 | 19 | # GLOBAL OPTIONS 20 | 21 | **--config, -c**="": Path to the configuration file 22 | 23 | 24 | # COMMANDS 25 | 26 | ## server 27 | 28 | Starts the tvhgo server 29 | 30 | ## admin 31 | 32 | Admin controls for the tvhgo server 33 | 34 | ### user 35 | 36 | Manage users of the tvhgo server 37 | 38 | #### add 39 | 40 | Add a new user 41 | 42 | **--display-name, -n**="": Display name of the new user 43 | 44 | **--email, -e**="": Email of the new user 45 | 46 | **--password, -p**="": Password of the new user 47 | 48 | **--username, -u**="": Username of the new user 49 | 50 | #### list 51 | 52 | List users. 53 | 54 | #### delete 55 | 56 | Deletes a user 57 | 58 | **--username, -u**="": Username of the new user 59 | 60 | #### 2fa 61 | 62 | Manage 2FA of a user 63 | 64 | ##### disable 65 | 66 | Disable 2FA for a user. 67 | 68 | **--username, -u**="": Username of the new user 69 | 70 | #### token 71 | 72 | Manage tokens of a user 73 | 74 | ##### list 75 | 76 | List tokens of a user 77 | 78 | **--username, -u**="": Username of the new user 79 | 80 | ##### generate 81 | 82 | Generate a new token 83 | 84 | **--name, -n**="": Name of the token, 85 | 86 | **--username, -u**="": Username of the new user 87 | 88 | ##### revoke 89 | 90 | Revokes a token 91 | 92 | **--id**="": ID of the token, 93 | -------------------------------------------------------------------------------- /docs/fail2ban.md: -------------------------------------------------------------------------------- 1 | Fail2Ban will prevent attackers to brute force your tvhgo instance. This is important if your server is publicly available. 2 | 3 | ## Filter 4 | 5 | Create a new filter file at `/etc/fail2ban/filter.d/tvhgo.local`: 6 | 7 | ```ini 8 | [Definition] 9 | failregex = .*login failed: invalid username or password.*ip= username=.* 10 | ignoreregex = 11 | ``` 12 | 13 | ## Jail 14 | 15 | Create a new jail file at `/etc/fail2ban/jail.d/tvhgo.local`: 16 | 17 | ```ini 18 | [tvhgo] 19 | enabled = true 20 | filter = tvhgo 21 | maxretry = 3 22 | bantime = 14400 23 | findtime = 14400 24 | action = iptables-allports[chain="INPUT"] 25 | ``` 26 | 27 | **Info for docker users** 28 | 29 | For docker you have to use the FORWARD chain instead of the INPUT chain: 30 | 31 | ```ini 32 | action = iptables-allports[chain="FORWARD"] 33 | ``` 34 | -------------------------------------------------------------------------------- /docs/images/grafana-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidborzek/tvhgo/025a92a77c90ac64473451a5ad04097fc81f00d0/docs/images/grafana-dashboard.png -------------------------------------------------------------------------------- /docs/images/tvhgo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidborzek/tvhgo/025a92a77c90ac64473451a5ad04097fc81f00d0/docs/images/tvhgo.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # tvhgo 2 | 3 |

4 | tvhgo 5 |

6 | 7 | tvhgo provides a modern and secure alternative for the Tvheadend web interface and the api. It aims to give users an all-round access to Tvheadend from any platform. 8 | 9 | [:octicons-arrow-right-24: Getting started](installation.md) 10 | 11 | ## Features 12 | 13 | This is a list of available features. 14 | 15 | - Channel list 16 | - TV Guide 17 | - Create and manage recordings 18 | - Two-Factor-Authentication 19 | - Multiple Languages (English/German) 20 | 21 | ## Contributing 22 | 23 | Contributing and pull requests are very welcome. 24 | 25 | More information about contributing to this project can be found [here](https://github.com/davidborzek/tvhgo/blob/main/CONTRIBUTING.md). 26 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | There are various methods available to install tvhgo. For detailed instructions you can follow these guides: 2 | 3 | - [Docker](./installation/docker.md) 4 | - [Helm](./installation/helm.md) 5 | -------------------------------------------------------------------------------- /docs/installation/docker.md: -------------------------------------------------------------------------------- 1 | This guide provides step-by-step instructions to install thvgo using docker. 2 | 3 | ## Prerequisites 4 | 5 | Before you start, make sure you have the following ready: 6 | 7 | - Docker 8 | 9 | ## Install tvhgo 10 | 11 | You can use the following command to run tvhgo using docker. 12 | 13 | ```bash 14 | $ docker run -d \ 15 | --name tvhgo \ 16 | -p 8080:8080 \ 17 | -e 'TVHGO_TVHEADEND_HOST=' \ 18 | -v /path/to/store/data:/tvhgo \ 19 | --restart unless-stopped \ 20 | ghcr.io/davidborzek/tvhgo:latest 21 | ``` 22 | 23 | > Note: tvhgo runs as a non-root user inside the container. Make sure the mounted directory is writable by the user with UID 1000 (default). 24 | 25 | Replace `` with the actual hostname or ip of your tvheadend server and adapt /path/to/store/data to a path of your choice to persist the data stored by thvgo. 26 | 27 | You can find more configuration options [here](../configuration.md). 28 | 29 | ## Create a user 30 | 31 | To complete the setup you need to create a user. 32 | 33 | ```bash 34 | docker exec -it tvhgo tvhgo admin user add 35 | ``` 36 | 37 | Follow the interactive setup to create a new user. 38 | -------------------------------------------------------------------------------- /docs/rapidoc.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | tvhgo REST API documentation 10 | 11 | 12 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /docs/screenshots/channel_events.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidborzek/tvhgo/025a92a77c90ac64473451a5ad04097fc81f00d0/docs/screenshots/channel_events.png -------------------------------------------------------------------------------- /docs/screenshots/channel_events_mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidborzek/tvhgo/025a92a77c90ac64473451a5ad04097fc81f00d0/docs/screenshots/channel_events_mobile.png -------------------------------------------------------------------------------- /docs/screenshots/channels.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidborzek/tvhgo/025a92a77c90ac64473451a5ad04097fc81f00d0/docs/screenshots/channels.png -------------------------------------------------------------------------------- /docs/screenshots/channels_mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidborzek/tvhgo/025a92a77c90ac64473451a5ad04097fc81f00d0/docs/screenshots/channels_mobile.png -------------------------------------------------------------------------------- /docs/screenshots/event.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidborzek/tvhgo/025a92a77c90ac64473451a5ad04097fc81f00d0/docs/screenshots/event.png -------------------------------------------------------------------------------- /docs/screenshots/event_mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidborzek/tvhgo/025a92a77c90ac64473451a5ad04097fc81f00d0/docs/screenshots/event_mobile.png -------------------------------------------------------------------------------- /docs/screenshots/guide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidborzek/tvhgo/025a92a77c90ac64473451a5ad04097fc81f00d0/docs/screenshots/guide.png -------------------------------------------------------------------------------- /docs/screenshots/guide_mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidborzek/tvhgo/025a92a77c90ac64473451a5ad04097fc81f00d0/docs/screenshots/guide_mobile.png -------------------------------------------------------------------------------- /docs/screenshots/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidborzek/tvhgo/025a92a77c90ac64473451a5ad04097fc81f00d0/docs/screenshots/overview.png -------------------------------------------------------------------------------- /health/health.go: -------------------------------------------------------------------------------- 1 | package health 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/alexliesenfeld/health" 8 | "github.com/davidborzek/tvhgo/db" 9 | "github.com/davidborzek/tvhgo/tvheadend" 10 | "github.com/go-chi/chi/v5" 11 | ) 12 | 13 | type healthRouter struct { 14 | tvhc tvheadend.Client 15 | db *db.DB 16 | } 17 | 18 | func New(tvhc tvheadend.Client, db *db.DB) *healthRouter { 19 | return &healthRouter{ 20 | tvhc: tvhc, 21 | db: db, 22 | } 23 | } 24 | 25 | func (h *healthRouter) Handler() http.Handler { 26 | r := chi.NewRouter() 27 | livenessChecker := health.NewChecker() 28 | 29 | readinessChecker := health.NewChecker( 30 | health.WithTimeout(10*time.Second), 31 | health.WithCheck(health.Check{ 32 | Name: "database", 33 | Check: h.db.PingContext, 34 | }), 35 | health.WithCheck(health.Check{ 36 | Name: "tvheadend", 37 | Check: h.tvheadendCheck, 38 | }), 39 | ) 40 | 41 | r.Handle("/liveness", health.NewHandler(livenessChecker)) 42 | r.Handle("/readiness", health.NewHandler(readinessChecker)) 43 | 44 | return r 45 | } 46 | -------------------------------------------------------------------------------- /health/tvheadend.go: -------------------------------------------------------------------------------- 1 | package health 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | func (r *healthRouter) tvheadendCheck(ctx context.Context) error { 9 | res, err := r.tvhc.Exec(ctx, "/status.xml", nil) 10 | if err != nil { 11 | return err 12 | } 13 | 14 | if res.StatusCode >= 400 { 15 | return fmt.Errorf("tvheadend returned erroneous status code: %d", res.StatusCode) 16 | } 17 | 18 | return nil 19 | } 20 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/davidborzek/tvhgo/cmd" 8 | ) 9 | 10 | // @title tvhgo 11 | // @version 1.0 12 | // @description tvhgo REST API documentation. 13 | 14 | // @BasePath /api 15 | 16 | func main() { 17 | if err := cmd.App.Run(os.Args); err != nil { 18 | fmt.Println(err.Error()) 19 | os.Exit(1) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /metrics/server.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/davidborzek/tvhgo/config" 8 | "github.com/go-chi/chi/v5" 9 | "github.com/go-chi/chi/v5/middleware" 10 | "github.com/prometheus/client_golang/prometheus" 11 | "github.com/prometheus/client_golang/prometheus/promhttp" 12 | "github.com/rs/zerolog/log" 13 | ) 14 | 15 | type server struct { 16 | cfg *config.MetricsConfig 17 | collectors []prometheus.Collector 18 | } 19 | 20 | func NewServer(cfg *config.MetricsConfig, collectors ...prometheus.Collector) *server { 21 | return &server{ 22 | cfg: cfg, 23 | collectors: collectors, 24 | } 25 | } 26 | 27 | func (s *server) Start() { 28 | r := chi.NewRouter() 29 | r.Use(middleware.Recoverer) 30 | r.Use(middleware.NoCache) 31 | 32 | r.Use(s.authenticate) 33 | 34 | for _, collector := range s.collectors { 35 | prometheus.MustRegister(collector) 36 | } 37 | 38 | r.Handle("/metrics", promhttp.Handler()) 39 | 40 | addr := s.cfg.Addr() 41 | log.Info().Str("addr", addr). 42 | Msg("starting the metrics http server") 43 | 44 | go func() { 45 | if err := http.ListenAndServe(addr, r); err != nil { 46 | log.Fatal().Err(err).Msg("failed to start metrics http server") 47 | } 48 | }() 49 | } 50 | 51 | func (s *server) authenticate(next http.Handler) http.Handler { 52 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 53 | token := strings.ReplaceAll( 54 | r.Header.Get("Authorization"), 55 | "Bearer ", "") 56 | 57 | if len(s.cfg.Token) != 0 && token != s.cfg.Token { 58 | w.WriteHeader(http.StatusUnauthorized) 59 | return 60 | } 61 | 62 | next.ServeHTTP(w, r) 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /mock/core/mock.go: -------------------------------------------------------------------------------- 1 | //go:build !prod 2 | // +build !prod 3 | 4 | package mock_core 5 | 6 | //go:generate mockgen -destination=mock_gen.go github.com/davidborzek/tvhgo/core UserRepository,SessionRepository,Clock,TwoFactorAuthService,TwoFactorSettingsRepository,TokenRepository,TokenService,SessionManager,ChannelService 7 | -------------------------------------------------------------------------------- /mock/tvheadend/mock.go: -------------------------------------------------------------------------------- 1 | //go:build !prod 2 | // +build !prod 3 | 4 | package mock_tvheadend 5 | 6 | import ( 7 | context "context" 8 | "errors" 9 | "net/http" 10 | 11 | tvheadend "github.com/davidborzek/tvhgo/tvheadend" 12 | ) 13 | 14 | func MockClientExecReturnsError( 15 | ctx context.Context, 16 | path string, 17 | dst interface{}, 18 | query ...tvheadend.Query, 19 | ) (*tvheadend.Response, error) { 20 | return nil, errors.New("error") 21 | } 22 | 23 | func MockClientExecReturnsErroneousHttpStatus( 24 | ctx context.Context, 25 | path string, 26 | dst interface{}, 27 | query ...tvheadend.Query, 28 | ) (*tvheadend.Response, error) { 29 | res := &tvheadend.Response{ 30 | Response: &http.Response{ 31 | StatusCode: 500, 32 | Body: http.NoBody, 33 | }, 34 | } 35 | 36 | return res, nil 37 | } 38 | 39 | //go:generate mockgen -destination=mock_gen.go github.com/davidborzek/tvhgo/tvheadend Client 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tvhgo", 3 | "version": "1.0.0", 4 | "description": "Modern and secure api and web interface for Tvheadend", 5 | "scripts": { 6 | "prepare": "husky && yarn --cwd ui install", 7 | "pre-commit": "lint-staged" 8 | }, 9 | "license": "MIT", 10 | "devDependencies": { 11 | "husky": "^9.0.11", 12 | "lint-staged": "^15.2.2" 13 | }, 14 | "lint-staged": { 15 | "*.go": [ 16 | "gofmt -w" 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "packageRules": [ 4 | { 5 | "matchUpdateTypes": ["major", "minor", "patch"], 6 | "automerge": true 7 | } 8 | ], 9 | "extends": ["config:base"] 10 | } 11 | -------------------------------------------------------------------------------- /repository/scanner.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | // Scanner interface represent a model that can be scanned into values. 4 | // Used by the internal helpers of each store to scan sql.Row or sql.Rows into models. 5 | type Scanner interface { 6 | Scan(dest ...interface{}) error 7 | } 8 | -------------------------------------------------------------------------------- /repository/session/query.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | // Base select query 4 | const queryBase = ` 5 | SELECT 6 | "session".id, 7 | "session".user_id, 8 | "session".hashed_token, 9 | "session".client_ip, 10 | "session".user_agent, 11 | "session".created_at, 12 | "session".last_used_at, 13 | "session".rotated_at 14 | FROM "session" 15 | ` 16 | 17 | // Select session by token and expiration 18 | const queryByToken = queryBase + ` 19 | WHERE 20 | "session".hashed_token = $1 21 | ` 22 | 23 | // Select sessions by user id 24 | const queryByUserID = queryBase + ` 25 | WHERE 26 | "session".user_id = $1 27 | ORDER BY "session".last_used_at DESC 28 | ` 29 | 30 | // Insert session statement 31 | const stmtInsert = ` 32 | INSERT INTO "session" ( 33 | user_id, 34 | hashed_token, 35 | client_ip, 36 | user_agent, 37 | created_at, 38 | last_used_at, 39 | rotated_at 40 | ) VALUES ( 41 | $1, $2, $3, $4, $5, $6, $7 42 | ) 43 | ` 44 | 45 | const stmtInsertPostgres = ` 46 | INSERT INTO "session" ( 47 | user_id, 48 | hashed_token, 49 | client_ip, 50 | user_agent, 51 | created_at, 52 | last_used_at, 53 | rotated_at 54 | ) VALUES ( 55 | $1, $2, $3, $4, $5, $6, $7 56 | ) RETURNING id 57 | ` 58 | 59 | // Update user statement. 60 | const stmtUpdate = ` 61 | UPDATE "session" SET 62 | hashed_token = $1, 63 | client_ip = $2, 64 | user_agent = $3, 65 | last_used_at = $4, 66 | rotated_at = $5 67 | WHERE id = $6 68 | ` 69 | 70 | // Delete session statement 71 | const stmtDelete = ` 72 | DELETE FROM "session" 73 | WHERE "session".id=$1 AND 74 | "session".user_id=$2 75 | ` 76 | 77 | // Delete expired sessions statement 78 | const stmtDeleteExpired = ` 79 | DELETE FROM "session" 80 | WHERE "session"."created_at" < $1 81 | OR "session"."last_used_at" < $2 82 | ` 83 | -------------------------------------------------------------------------------- /repository/session/scan.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/davidborzek/tvhgo/core" 7 | "github.com/davidborzek/tvhgo/repository" 8 | ) 9 | 10 | // Internal helper to scan a sql.Row into a session model. 11 | func scanRow(scanner repository.Scanner, dest *core.Session) error { 12 | return scanner.Scan( 13 | &dest.ID, 14 | &dest.UserId, 15 | &dest.HashedToken, 16 | &dest.ClientIP, 17 | &dest.UserAgent, 18 | &dest.CreatedAt, 19 | &dest.LastUsedAt, 20 | &dest.RotatedAt, 21 | ) 22 | } 23 | 24 | // Internal helper to scan sql.Rows into an array of user models. 25 | func scanRows(rows *sql.Rows) ([]*core.Session, error) { 26 | defer rows.Close() 27 | 28 | sessions := []*core.Session{} 29 | for rows.Next() { 30 | session := new(core.Session) 31 | if err := scanRow(rows, session); err != nil { 32 | return nil, err 33 | } 34 | sessions = append(sessions, session) 35 | } 36 | return sessions, nil 37 | } 38 | -------------------------------------------------------------------------------- /repository/token/query.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | const queryBase = ` 4 | SELECT 5 | token.id, 6 | token.user_id, 7 | token.name, 8 | token.hashed_token, 9 | token.created_at, 10 | token.updated_at 11 | FROM token 12 | ` 13 | 14 | const queryByToken = queryBase + ` 15 | WHERE token.hashed_token = $1 16 | ` 17 | 18 | const queryByUser = queryBase + ` 19 | WHERE token.user_id = $1 20 | ` 21 | 22 | const stmtInsert = ` 23 | INSERT INTO token ( 24 | user_id, 25 | hashed_token, 26 | name, 27 | created_at, 28 | updated_at 29 | ) VALUES ( 30 | $1, 31 | $2, 32 | $3, 33 | $4, 34 | $5 35 | ) 36 | ` 37 | 38 | const stmtInsertPostgres = ` 39 | INSERT INTO token ( 40 | user_id, 41 | hashed_token, 42 | name, 43 | created_at, 44 | updated_at 45 | ) VALUES ( 46 | $1, 47 | $2, 48 | $3, 49 | $4, 50 | $5 51 | ) RETURNING id 52 | ` 53 | 54 | const stmtDelete = ` 55 | DELETE FROM token WHERE id = $1 56 | ` 57 | -------------------------------------------------------------------------------- /repository/token/scan.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/davidborzek/tvhgo/core" 7 | "github.com/davidborzek/tvhgo/repository" 8 | ) 9 | 10 | // Internal helper to scan a sql.Row into a token model. 11 | func scanRow(scanner repository.Scanner, dest *core.Token) error { 12 | return scanner.Scan( 13 | &dest.ID, 14 | &dest.UserID, 15 | &dest.Name, 16 | &dest.HashedToken, 17 | &dest.CreatedAt, 18 | &dest.UpdatedAt, 19 | ) 20 | } 21 | 22 | // Internal helper to scan sql.Rows into an array of user models. 23 | func scanRows(rows *sql.Rows) ([]*core.Token, error) { 24 | defer rows.Close() 25 | 26 | tokens := []*core.Token{} 27 | for rows.Next() { 28 | token := new(core.Token) 29 | if err := scanRow(rows, token); err != nil { 30 | return nil, err 31 | } 32 | tokens = append(tokens, token) 33 | } 34 | return tokens, nil 35 | } 36 | -------------------------------------------------------------------------------- /repository/two_factor_settings/query.go: -------------------------------------------------------------------------------- 1 | package twofactorsettings 2 | 3 | const queryBase = ` 4 | SELECT 5 | two_factor_settings.user_id, 6 | two_factor_settings.secret, 7 | two_factor_settings.enabled, 8 | two_factor_settings.created_at, 9 | two_factor_settings.updated_at 10 | FROM two_factor_settings 11 | ` 12 | 13 | const queryByUserID = queryBase + ` 14 | WHERE two_factor_settings.user_id = $1 15 | ` 16 | 17 | const stmtInsert = ` 18 | INSERT INTO two_factor_settings ( 19 | user_id, 20 | secret, 21 | enabled, 22 | created_at, 23 | updated_at 24 | ) VALUES ( 25 | $1, $2, $3, $4, $5 26 | ) 27 | ` 28 | const stmtUpdate = ` 29 | UPDATE two_factor_settings SET 30 | secret = $1, 31 | enabled = $2, 32 | updated_at = $3 33 | WHERE user_id = $4 34 | ` 35 | 36 | const stmtDelete = ` 37 | DELETE FROM two_factor_settings WHERE user_id = $1 38 | ` 39 | -------------------------------------------------------------------------------- /repository/two_factor_settings/scan.go: -------------------------------------------------------------------------------- 1 | package twofactorsettings 2 | 3 | import ( 4 | "github.com/davidborzek/tvhgo/core" 5 | "github.com/davidborzek/tvhgo/repository" 6 | ) 7 | 8 | // Internal helper to scan a sql.Row into a user model. 9 | func scanRow(scanner repository.Scanner, dest *core.TwoFactorSettings) error { 10 | return scanner.Scan( 11 | &dest.UserID, 12 | &dest.Secret, 13 | &dest.Enabled, 14 | &dest.CreatedAt, 15 | &dest.UpdatedAt, 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /repository/user/query.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | // Count users query. 4 | const queryCount = ` 5 | SELECT COUNT(*) FROM "user" 6 | ` 7 | 8 | // Select user query base. 9 | const queryBase = ` 10 | SELECT 11 | "user".id, 12 | "user".username, 13 | "user".password_hash, 14 | "user".email, 15 | "user".display_name, 16 | "user".is_admin, 17 | "user".created_at, 18 | "user".updated_at, 19 | two_factor_settings.enabled 20 | FROM "user" 21 | LEFT JOIN two_factor_settings ON "user".id = two_factor_settings.user_id 22 | ` 23 | 24 | // Select user by id query. 25 | const queryById = queryBase + ` 26 | WHERE 27 | "user".id = $1 28 | ` 29 | 30 | // Select user by username query 31 | const queryByUsername = queryBase + ` 32 | WHERE 33 | "user".username = $1 34 | ` 35 | 36 | // Select user by email query 37 | const queryByEmail = queryBase + ` 38 | WHERE 39 | "user".email = $1 40 | ` 41 | 42 | // Insert user statement. 43 | const stmtInsert = ` 44 | INSERT INTO "user" ( 45 | username, 46 | password_hash, 47 | email, 48 | display_name, 49 | is_admin, 50 | created_at, 51 | updated_at 52 | ) VALUES ( 53 | $1, $2, $3, $4, $5, $6, $7 54 | ) 55 | ` 56 | 57 | // Insert user statement. 58 | const stmtInsertPostgres = ` 59 | INSERT INTO "user" ( 60 | username, 61 | password_hash, 62 | email, 63 | display_name, 64 | is_admin, 65 | created_at, 66 | updated_at 67 | ) VALUES ( 68 | $1, $2, $3, $4, $5, $6, $7 69 | ) RETURNING id 70 | ` 71 | 72 | // Delete user statement. 73 | const stmtDelete = ` 74 | DELETE FROM "user" 75 | WHERE "user".id = $1 76 | ` 77 | 78 | // Update user statement. 79 | const stmtUpdate = ` 80 | UPDATE "user" SET 81 | username = $1, 82 | password_hash = $2, 83 | email = $3, 84 | display_name = $4, 85 | is_admin = $5, 86 | updated_at = $6 87 | WHERE id = $7 88 | ` 89 | -------------------------------------------------------------------------------- /repository/user/scan.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/davidborzek/tvhgo/core" 7 | "github.com/davidborzek/tvhgo/repository" 8 | ) 9 | 10 | // Internal helper to scan a sql.Row into a user model. 11 | func scanRow(scanner repository.Scanner, dest *core.User) error { 12 | var ( 13 | twoFactor sql.NullBool 14 | ) 15 | 16 | err := scanner.Scan( 17 | &dest.ID, 18 | &dest.Username, 19 | &dest.PasswordHash, 20 | &dest.Email, 21 | &dest.DisplayName, 22 | &dest.IsAdmin, 23 | &dest.CreatedAt, 24 | &dest.UpdatedAt, 25 | &twoFactor, 26 | ) 27 | 28 | if err != nil { 29 | return err 30 | } 31 | 32 | if twoFactor.Valid { 33 | dest.TwoFactor = twoFactor.Bool 34 | } 35 | 36 | return nil 37 | } 38 | 39 | // Internal helper to scan sql.Rows into an array of user models. 40 | func scanRows(rows *sql.Rows) ([]*core.User, error) { 41 | defer rows.Close() 42 | 43 | users := []*core.User{} 44 | for rows.Next() { 45 | user := new(core.User) 46 | if err := scanRow(rows, user); err != nil { 47 | return nil, err 48 | } 49 | users = append(users, user) 50 | } 51 | return users, nil 52 | } 53 | -------------------------------------------------------------------------------- /services/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import "golang.org/x/crypto/bcrypt" 4 | 5 | const bcryptCost = 12 6 | 7 | // HashPassword hashes the password using bcrypt. 8 | func HashPassword(password string) (string, error) { 9 | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) 10 | if err != nil { 11 | return "", err 12 | } 13 | 14 | return string(hash), nil 15 | } 16 | 17 | func ComparePassword(password string, hash string) error { 18 | return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) 19 | } 20 | -------------------------------------------------------------------------------- /services/auth/local.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/davidborzek/tvhgo/core" 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | func NewLocalPasswordAuthenticator( 11 | userRepository core.UserRepository, 12 | twoFactorService core.TwoFactorAuthService, 13 | ) *localPasswordAuthenticator { 14 | return &localPasswordAuthenticator{ 15 | userRepository: userRepository, 16 | twoFactorService: twoFactorService, 17 | } 18 | } 19 | 20 | type localPasswordAuthenticator struct { 21 | userRepository core.UserRepository 22 | twoFactorService core.TwoFactorAuthService 23 | } 24 | 25 | func (s *localPasswordAuthenticator) Login( 26 | ctx context.Context, 27 | login string, 28 | password string, 29 | totp *string, 30 | ) (*core.User, error) { 31 | user, err := s.userRepository.FindByUsername(ctx, login) 32 | if err != nil { 33 | log.Error().Str("user", login). 34 | Err(err).Msg("failed to get user") 35 | 36 | return nil, core.ErrUnexpectedError 37 | } 38 | 39 | if user == nil { 40 | return nil, core.ErrInvalidUsernameOrPassword 41 | } 42 | 43 | if user.PasswordHash == "" { 44 | return nil, core.ErrInvalidUsernameOrPassword 45 | } 46 | 47 | if err := ComparePassword(password, user.PasswordHash); err != nil { 48 | return nil, core.ErrInvalidUsernameOrPassword 49 | } 50 | 51 | if err := s.twoFactorService.Verify(ctx, user.ID, totp); err != nil { 52 | return nil, err 53 | } 54 | 55 | return user, nil 56 | } 57 | 58 | func (s *localPasswordAuthenticator) ConfirmPassword( 59 | ctx context.Context, 60 | userID int64, 61 | password string, 62 | ) error { 63 | user, err := s.userRepository.FindById(ctx, userID) 64 | if err != nil { 65 | log.Error().Int64("userId", userID). 66 | Err(err).Msg("failed to find user for password confirmation") 67 | 68 | return core.ErrUnexpectedError 69 | } 70 | 71 | if err := ComparePassword(password, user.PasswordHash); err != nil { 72 | return core.ErrConfirmationPasswordInvalid 73 | } 74 | 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /services/auth/session_cleaner.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/davidborzek/tvhgo/core" 8 | "github.com/rs/zerolog/log" 9 | ) 10 | 11 | type sessionCleaner struct { 12 | sessions core.SessionRepository 13 | clock core.Clock 14 | interval time.Duration 15 | inactiveLifetime time.Duration 16 | lifetime time.Duration 17 | } 18 | 19 | func NewSessionCleaner( 20 | sessions core.SessionRepository, 21 | clock core.Clock, 22 | interval time.Duration, 23 | inactiveLifetime time.Duration, 24 | lifetime time.Duration, 25 | ) *sessionCleaner { 26 | return &sessionCleaner{ 27 | sessions: sessions, 28 | clock: clock, 29 | interval: interval, 30 | lifetime: lifetime, 31 | inactiveLifetime: inactiveLifetime, 32 | } 33 | } 34 | 35 | func (s *sessionCleaner) Start() { 36 | log.Info().Dur("interval", s.interval). 37 | Msg("starting session cleaner") 38 | 39 | ticker := time.NewTicker(s.interval) 40 | 41 | s.RunNow() 42 | 43 | go func() { 44 | for { 45 | <-ticker.C 46 | log.Debug().Msg("running scheduled session cleanup") 47 | s.RunNow() 48 | } 49 | }() 50 | } 51 | 52 | func (s *sessionCleaner) RunNow() { 53 | expirationDate := s.clock.Now().Add(-s.lifetime) 54 | inActiveExpirationDate := s.clock.Now().Add(-s.inactiveLifetime) 55 | 56 | rows, err := s.sessions.DeleteExpired( 57 | context.Background(), 58 | expirationDate.Unix(), 59 | inActiveExpirationDate.Unix(), 60 | ) 61 | 62 | if err != nil { 63 | log.Error().Err(err).Msg("failed to cleanup expired sessions") 64 | 65 | } else if rows > 0 { 66 | log.Debug().Int64("sessions", rows).Msg("cleaned up expired sessions") 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /services/clock/clock.go: -------------------------------------------------------------------------------- 1 | package clock 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/davidborzek/tvhgo/core" 7 | ) 8 | 9 | func NewClock() core.Clock { 10 | return &clockImpl{} 11 | } 12 | 13 | type clockImpl struct{} 14 | 15 | func (clockImpl) Now() time.Time { 16 | return time.Now() 17 | } 18 | -------------------------------------------------------------------------------- /services/picon/picon.go: -------------------------------------------------------------------------------- 1 | package picon 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | 9 | "github.com/davidborzek/tvhgo/core" 10 | "github.com/davidborzek/tvhgo/tvheadend" 11 | ) 12 | 13 | var ( 14 | ErrRequestFailed = errors.New("picon request failed") 15 | ) 16 | 17 | type service struct { 18 | tvh tvheadend.Client 19 | } 20 | 21 | func New(tvh tvheadend.Client) core.PiconService { 22 | return &service{ 23 | tvh: tvh, 24 | } 25 | } 26 | 27 | func (s *service) Get(ctx context.Context, id int) (io.Reader, error) { 28 | res, err := s.tvh.Exec(ctx, fmt.Sprintf("/imagecache/%d", id), nil) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | if res.StatusCode == 404 { 34 | return nil, core.ErrPiconNotFound 35 | } 36 | 37 | if res.StatusCode >= 400 { 38 | return nil, ErrRequestFailed 39 | } 40 | 41 | return res.Body, nil 42 | } 43 | -------------------------------------------------------------------------------- /services/profile/profile.go: -------------------------------------------------------------------------------- 1 | package profiles 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/davidborzek/tvhgo/core" 8 | "github.com/davidborzek/tvhgo/tvheadend" 9 | ) 10 | 11 | var ( 12 | ErrRequestFailed = errors.New("profile request failed") 13 | ) 14 | 15 | type service struct { 16 | tvh tvheadend.Client 17 | } 18 | 19 | func New(tvh tvheadend.Client) core.ProfileService { 20 | return &service{ 21 | tvh: tvh, 22 | } 23 | } 24 | 25 | func (s *service) GetStreamProfiles(ctx context.Context) ([]core.StreamProfile, error) { 26 | var list tvheadend.ListResponse[tvheadend.StreamProfile] 27 | res, err := s.tvh.Exec(ctx, "/api/profile/list", &list) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | if res.StatusCode >= 400 { 33 | return nil, ErrRequestFailed 34 | } 35 | 36 | profiles := make([]core.StreamProfile, 0) 37 | for _, entry := range list.Entries { 38 | profiles = append(profiles, core.NewStreamProfile(entry)) 39 | } 40 | 41 | return profiles, nil 42 | } 43 | -------------------------------------------------------------------------------- /services/streaming/streaming.go: -------------------------------------------------------------------------------- 1 | package streaming 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/davidborzek/tvhgo/core" 9 | "github.com/davidborzek/tvhgo/tvheadend" 10 | ) 11 | 12 | type service struct { 13 | tvh tvheadend.Client 14 | } 15 | 16 | func New(tvh tvheadend.Client) core.StreamingService { 17 | return &service{ 18 | tvh: tvh, 19 | } 20 | } 21 | 22 | func (s *service) GetChannelStream( 23 | ctx context.Context, 24 | channelNumber int64, 25 | profile string, 26 | ) (*http.Response, error) { 27 | q := tvheadend.NewQuery() 28 | 29 | if profile != "" { 30 | q.Set("profile", profile) 31 | } 32 | 33 | res, err := s.tvh.Exec(ctx, fmt.Sprintf("/stream/channelnumber/%d", channelNumber), nil, q) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | if res.StatusCode >= 400 { 39 | return nil, fmt.Errorf("unexpected status code: %d", res.StatusCode) 40 | } 41 | 42 | return res.Response, nil 43 | } 44 | 45 | func (s *service) GetRecordingStream( 46 | ctx context.Context, 47 | recordingId string, 48 | ) (*http.Response, error) { 49 | res, err := s.tvh.Exec(ctx, fmt.Sprintf("/dvrfile/%s", recordingId), nil) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | if res.StatusCode >= 400 { 55 | return nil, fmt.Errorf("unexpected status code: %d", res.StatusCode) 56 | } 57 | 58 | return res.Response, nil 59 | } 60 | -------------------------------------------------------------------------------- /ui/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | const prettierConfig = require('./.prettierrc.cjs'); 2 | 3 | module.exports = { 4 | root: true, 5 | env: { browser: true, es2020: true }, 6 | extends: [ 7 | 'eslint:recommended', 8 | 'plugin:@typescript-eslint/recommended', 9 | 'plugin:react-hooks/recommended', 10 | 'plugin:prettier/recommended', 11 | 'plugin:import/typescript', 12 | ], 13 | ignorePatterns: ['dist', '.eslintrc.cjs'], 14 | parser: '@typescript-eslint/parser', 15 | rules: { 16 | camelcase: 'warn', 17 | eqeqeq: 'error', 18 | 'no-duplicate-imports': 'error', 19 | 'no-unused-expressions': 'error', 20 | 'no-unused-labels': 'error', 21 | 'prefer-const': 'error', 22 | 'prefer-template': 'error', 23 | 'prettier/prettier': ['error', prettierConfig], 24 | 'sort-imports': 'error', 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /ui/.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'es5', 3 | semi: true, 4 | singleQuote: true, 5 | tabWidth: 2, 6 | printWidth: 80, 7 | plugins: ['prettier-plugin-import-sort'], 8 | }; 9 | -------------------------------------------------------------------------------- /ui/dist/keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidborzek/tvhgo/025a92a77c90ac64473451a5ad04097fc81f00d0/ui/dist/keep -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | tvhgo 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidborzek/tvhgo/025a92a77c90ac64473451a5ad04097fc81f00d0/ui/public/favicon.ico -------------------------------------------------------------------------------- /ui/public/img/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidborzek/tvhgo/025a92a77c90ac64473451a5ad04097fc81f00d0/ui/public/img/logo192.png -------------------------------------------------------------------------------- /ui/public/img/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidborzek/tvhgo/025a92a77c90ac64473451a5ad04097fc81f00d0/ui/public/img/logo512.png -------------------------------------------------------------------------------- /ui/public/img/tvhgo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidborzek/tvhgo/025a92a77c90ac64473451a5ad04097fc81f00d0/ui/public/img/tvhgo.png -------------------------------------------------------------------------------- /ui/public/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "tvhgo", 3 | "name": "tvhgo", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "32x32", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "img/logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "img/logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "orientation": "portrait", 24 | "theme_color": "#181b1f", 25 | "background_color": "#181b1f" 26 | } 27 | -------------------------------------------------------------------------------- /ui/src/__test__/ids.ts: -------------------------------------------------------------------------------- 1 | export enum TestIds { 2 | PAGINATION_FIRST_PAGE = 'pagination-first-page-btn', 3 | PAGINATION_PREVIOUS_PAGE = 'pagination-previous-page-btn', 4 | PAGINATION_NEXT_PAGE = 'pagination-next-page-btn', 5 | PAGINATION_LAST_PAGE = 'pagination-last-page-btn', 6 | REVOKE_SESSION_BUTTON = 'revoke-session-btn', 7 | REVOKE_TOKEN_BUTTON = 'revoke-token-btn', 8 | THEME_DROPDOWN = 'theme-dropdown', 9 | LANGUAGE_DROPDOWN = 'language-dropdown', 10 | TIME_FORMAT_DROPDOWN = 'time-format-dropdown', 11 | LOGOUT_BUTTON = 'logout-button', 12 | SAVE_USER_BUTTON = 'save-user-btn', 13 | TWOFA_DISABLE_BUTTON = 'twofa-disable-button', 14 | TWOFA_ENABLE_BUTTON = 'twofa-enable-button', 15 | SELECT_ALL_RECORDINGS_CHECKBOX = 'select-all-recordings-checkbox', 16 | SELECT_RECORDING_CHECKBOX = 'select-recording-checkbox', 17 | DELETE_CANCEL_RECORDINGS_BUTTON = 'delete-cancel-recordings-btn', 18 | CONFIRM_DELETE_BUTTON = 'confirm-delete-btn', 19 | RECORDINGS_STATUS_DROPDOWN = 'recordings-status-dropdown', 20 | RECORDINGS_SORT_DROPDOWN = 'recordings-sort-dropdown', 21 | RECORDINGS_SORT_DIR_BUTTON = 'recordings-sort-direction-btn', 22 | } 23 | -------------------------------------------------------------------------------- /ui/src/assets/arrow_right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /ui/src/assets/burger_menu.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /ui/src/assets/checkmark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /ui/src/assets/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | -------------------------------------------------------------------------------- /ui/src/assets/copy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /ui/src/assets/dash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /ui/src/assets/guide.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui/src/assets/index.ts: -------------------------------------------------------------------------------- 1 | import ArrowRightIcon from './arrow_right.svg?react'; 2 | import BurgerMenuIcon from './burger_menu.svg?react'; 3 | import Checkmark from './checkmark.svg?react'; 4 | import Close from './close.svg?react'; 5 | import Copy from './copy.svg?react'; 6 | import Dash from './dash.svg?react'; 7 | import GuideIcon from './guide.svg?react'; 8 | import LargeArrowLeftIcon from './large_arrow_left.svg?react'; 9 | import LargeArrowRightIcon from './large_arrow_right.svg?react'; 10 | import LogoutIcon from './logout.svg?react'; 11 | import RecIcon from './rec.svg?react'; 12 | import RecordingsIcon from './recordings.svg?react'; 13 | import SearchIcon from './search.svg?react'; 14 | import SettingsIcon from './settings.svg?react'; 15 | import TvIcon from './tv.svg?react'; 16 | import TvhgoHorizontalLogo from './tvhgo_horizontal.svg?react'; 17 | 18 | export { 19 | ArrowRightIcon, 20 | BurgerMenuIcon, 21 | Checkmark, 22 | Close, 23 | Copy, 24 | Dash, 25 | GuideIcon, 26 | LargeArrowLeftIcon, 27 | LargeArrowRightIcon, 28 | LogoutIcon, 29 | RecIcon, 30 | RecordingsIcon, 31 | SearchIcon, 32 | SettingsIcon, 33 | TvhgoHorizontalLogo, 34 | TvIcon, 35 | }; 36 | -------------------------------------------------------------------------------- /ui/src/assets/large_arrow_left.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui/src/assets/large_arrow_right.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui/src/assets/logout.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /ui/src/assets/rec.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ui/src/assets/recordings.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui/src/assets/search.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /ui/src/assets/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /ui/src/assets/tv.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ui/src/components/channels/listItem/ChannelListItem.module.scss: -------------------------------------------------------------------------------- 1 | .channel { 2 | display: flex; 3 | align-items: center; 4 | background-color: var(--color-card-bg); 5 | border-radius: 2px; 6 | margin: 1rem 0; 7 | cursor: pointer; 8 | } 9 | 10 | .piconContainer { 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | height: 2rem; 15 | width: 4rem; 16 | margin: 1rem; 17 | padding: 0.3rem; 18 | background-color: var(--color-border-subtle); 19 | border-radius: 2px; 20 | flex-shrink: 0; 21 | } 22 | 23 | .picon { 24 | height: 2rem; 25 | } 26 | 27 | .event { 28 | display: flex; 29 | flex-direction: column; 30 | margin: 0.5rem 0; 31 | overflow: hidden; 32 | } 33 | 34 | .channelName { 35 | font-weight: 500; 36 | overflow: hidden; 37 | text-overflow: ellipsis; 38 | white-space: nowrap; 39 | } 40 | 41 | .eventTitle { 42 | font-weight: 300; 43 | opacity: 0.8; 44 | overflow: hidden; 45 | text-overflow: ellipsis; 46 | white-space: nowrap; 47 | } 48 | -------------------------------------------------------------------------------- /ui/src/components/channels/listItem/ChannelListItem.tsx: -------------------------------------------------------------------------------- 1 | import { EpgEvent } from '@/clients/api/api.types'; 2 | import Image from '@/components/common/image/Image'; 3 | import styles from './ChannelListItem.module.scss'; 4 | import { useTranslation } from 'react-i18next'; 5 | 6 | type Props = { 7 | event: EpgEvent; 8 | onClick: (id: string) => void; 9 | }; 10 | 11 | function ChannelListItem({ event, onClick }: Props) { 12 | const { t } = useTranslation(); 13 | 14 | return ( 15 |
onClick(event.channelId)}> 16 |
17 | 23 |
24 |
25 | {event.channelName} 26 | {event.title} 27 | {t('event_time', { event })} 28 |
29 |
30 | ); 31 | } 32 | 33 | export default ChannelListItem; 34 | -------------------------------------------------------------------------------- /ui/src/components/common/badge/Badge.module.scss: -------------------------------------------------------------------------------- 1 | .badge { 2 | border-radius: 8px; 3 | padding: 0.2rem 0.5rem; 4 | font-size: 0.8rem; 5 | font-weight: 600; 6 | 7 | &.default { 8 | background-color: rgba(142, 255, 255, 0.7); 9 | color: rgb(39, 78, 78); 10 | } 11 | 12 | &.success { 13 | background-color: rgba(142, 255, 142, 0.7); 14 | color: rgb(39, 78, 39); 15 | } 16 | 17 | &.failure { 18 | background-color: rgba(255, 142, 142, 0.7); 19 | color: rgb(78, 39, 39); 20 | } 21 | 22 | &.warning { 23 | background-color: rgba(255, 255, 142, 0.7); 24 | color: rgb(78, 78, 39); 25 | } 26 | 27 | &.indigo { 28 | background-color: rgba(142, 142, 255, 0.7); 29 | color: rgb(39, 39, 78); 30 | } 31 | 32 | &.pink { 33 | background-color: rgba(255, 142, 255, 0.7); 34 | color: rgb(78, 39, 78); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /ui/src/components/common/badge/Badge.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentPropsWithRef } from 'react'; 2 | import { c } from '@/utils/classNames'; 3 | import styles from './Badge.module.scss'; 4 | 5 | type BadgeProps = ComponentPropsWithRef<'span'> & { 6 | color?: 'default' | 'success' | 'failure' | 'warning' | 'indigo' | 'pink'; 7 | }; 8 | 9 | export default function Badge({ 10 | children, 11 | className, 12 | color = 'default', 13 | ...rest 14 | }: BadgeProps) { 15 | return ( 16 | 20 | {children} 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /ui/src/components/common/button/Button.module.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | border: 1px solid #58a6ff; 3 | border-radius: 2px; 4 | background-color: #58a6ff; 5 | color: white; 6 | cursor: pointer; 7 | font-weight: 600; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | 12 | &.red { 13 | border: 1px solid var(--color-danger-bg); 14 | background-color: var(--color-danger-bg); 15 | } 16 | 17 | &.quiet { 18 | border: none; 19 | background-color: transparent; 20 | color: #58a6ff; 21 | 22 | &:hover:not(&.disabled) { 23 | background-color: rgba(88, 166, 255, 0.1); 24 | 25 | &.red { 26 | background-color: rgba(250, 153, 153, 0.3); 27 | } 28 | } 29 | 30 | &.red { 31 | color: var(--color-danger-bg); 32 | 33 | svg { 34 | stroke: var(--color-danger-bg); 35 | } 36 | } 37 | 38 | &.text { 39 | color: var(--color-text-primary); 40 | 41 | svg { 42 | stroke: var(--color-text-primary); 43 | } 44 | } 45 | 46 | svg { 47 | stroke: #58a6ff; 48 | } 49 | } 50 | 51 | &.disabled { 52 | opacity: 0.5; 53 | cursor: not-allowed; 54 | } 55 | 56 | &:hover:not(&.disabled) { 57 | opacity: 0.8; 58 | } 59 | 60 | svg { 61 | width: 0.8rem; 62 | height: 0.8rem; 63 | margin: 0; 64 | padding: 0; 65 | stroke: var(--color-text-primary); 66 | } 67 | 68 | &.medium { 69 | padding: 0.5rem; 70 | font-size: 0.8rem; 71 | } 72 | 73 | &.small { 74 | padding: 0.3rem; 75 | font-size: 0.8rem; 76 | } 77 | 78 | &.large { 79 | padding: 0.7rem; 80 | font-size: 1rem; 81 | } 82 | } 83 | 84 | a.button { 85 | text-decoration: none; 86 | } 87 | -------------------------------------------------------------------------------- /ui/src/components/common/button/Button.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | 3 | import { c } from '@/utils/classNames'; 4 | import styles from './Button.module.scss'; 5 | 6 | export type ButtonStyle = 'red' | 'blue' | 'text'; 7 | 8 | type Props = { 9 | label?: string; 10 | icon?: ReactElement; 11 | type?: 'submit' | 'reset' | 'button'; 12 | size?: 'small' | 'medium' | 'large'; 13 | disabled?: boolean; 14 | quiet?: boolean; 15 | loading?: boolean; 16 | loadingLabel?: string | null; 17 | className?: string; 18 | style?: ButtonStyle; 19 | testID?: string; 20 | onClick?: React.MouseEventHandler; 21 | }; 22 | 23 | export const getStyleClass = (style?: ButtonStyle) => { 24 | switch (style) { 25 | case 'red': 26 | return styles.red; 27 | case 'text': 28 | return styles.text; 29 | } 30 | return ''; 31 | }; 32 | 33 | function Button(props: Props) { 34 | const disabled = props.disabled || props.loading; 35 | 36 | const getLabel = () => { 37 | if (props.loading) { 38 | return props.loadingLabel || '...'; 39 | } 40 | 41 | return props.label || ''; 42 | }; 43 | 44 | return ( 45 | 62 | ); 63 | } 64 | 65 | export default Button; 66 | -------------------------------------------------------------------------------- /ui/src/components/common/button/ButtonLink.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonStyle, getStyleClass } from './Button'; 2 | 3 | import { c } from '@/utils/classNames'; 4 | import styles from './Button.module.scss'; 5 | 6 | type Props = { 7 | label: string; 8 | download?: string | boolean; 9 | size?: 'small' | 'medium' | 'large'; 10 | href?: string; 11 | quiet?: boolean; 12 | className?: string; 13 | style?: ButtonStyle; 14 | }; 15 | 16 | const ButtonLink = (props: Props) => { 17 | return ( 18 | 29 | {props.label} 30 | 31 | ); 32 | }; 33 | 34 | export default ButtonLink; 35 | -------------------------------------------------------------------------------- /ui/src/components/common/checkbox/Checkbox.module.scss: -------------------------------------------------------------------------------- 1 | .checkbox { 2 | margin: 0; 3 | width: 1rem; 4 | height: 1rem; 5 | outline: none; 6 | box-sizing: border-box; 7 | position: absolute; 8 | cursor: pointer; 9 | 10 | appearance: none; 11 | -webkit-appearance: none; 12 | -moz-appearance: none; 13 | } 14 | 15 | .container { 16 | width: 1rem; 17 | height: 1rem; 18 | border: 2px solid var(--color-accent-contrast); 19 | background-color: var(--color-bg); 20 | box-sizing: border-box; 21 | border-radius: 4px; 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | -webkit-user-select: none; 26 | -moz-user-select: none; 27 | user-select: none; 28 | cursor: pointer; 29 | position: relative; 30 | 31 | .mark { 32 | width: 1rem; 33 | height: 1rem; 34 | } 35 | 36 | &:hover:not(.disabled) { 37 | border-color: var(--color-accent); 38 | } 39 | 40 | &.checked { 41 | background-color: var(--color-accent); 42 | border-color: var(--color-accent); 43 | } 44 | 45 | &.disabled { 46 | cursor: not-allowed; 47 | opacity: 0.5; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /ui/src/components/common/checkbox/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { Checkmark, Dash } from '@/assets'; 2 | 3 | import { c } from '@/utils/classNames'; 4 | import styles from './Checkbox.module.scss'; 5 | 6 | type Props = { 7 | onChange?: (checked: boolean) => void; 8 | checked?: boolean; 9 | disabled?: boolean; 10 | indeterminate?: boolean; 11 | className?: string; 12 | testId?: string; 13 | name?: string; 14 | }; 15 | 16 | const Checkbox = (props: Props) => { 17 | const getMark = () => { 18 | if (props.checked) { 19 | return ; 20 | } 21 | 22 | if (props.indeterminate) { 23 | return ; 24 | } 25 | }; 26 | 27 | return ( 28 |
{ 36 | if (!props.disabled && props.onChange && e.target === e.currentTarget) { 37 | props.onChange(!props.checked); 38 | } 39 | }} 40 | > 41 | {getMark()} 42 | { 46 | if (props.onChange) { 47 | props.onChange(!props.checked); 48 | } 49 | }} 50 | checked={props.checked} 51 | disabled={props.disabled} 52 | data-testid={props.testId} 53 | name={props.name} 54 | /> 55 |
56 | ); 57 | }; 58 | 59 | export default Checkbox; 60 | -------------------------------------------------------------------------------- /ui/src/components/common/deleteConfirmationModal/DeleteConfirmationModal.module.scss: -------------------------------------------------------------------------------- 1 | .content { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | padding: 2.5rem; 6 | } 7 | 8 | .headline { 9 | font-size: 1.5rem; 10 | text-align: center; 11 | max-width: 30rem; 12 | } 13 | -------------------------------------------------------------------------------- /ui/src/components/common/deleteConfirmationModal/DeleteConfirmationModal.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@/components/common/button/Button'; 2 | import Modal from '@/components/common/modal/Modal'; 3 | import { TestIds } from '@/__test__/ids'; 4 | import styles from './DeleteConfirmationModal.module.scss'; 5 | import { useTranslation } from 'react-i18next'; 6 | 7 | type Props = { 8 | title?: string | null | undefined; 9 | buttonTitle?: string | null | undefined; 10 | visible: boolean; 11 | onClose: () => void; 12 | onConfirm: () => void; 13 | pending?: boolean; 14 | }; 15 | 16 | const DeleteConfirmationModal = ({ 17 | visible, 18 | onClose, 19 | onConfirm, 20 | title, 21 | buttonTitle, 22 | pending, 23 | }: Props) => { 24 | const { t } = useTranslation(); 25 | 26 | return ( 27 | 28 |
29 | {title ?

{title}

: <>} 30 |
38 |
39 | ); 40 | }; 41 | 42 | export default DeleteConfirmationModal; 43 | -------------------------------------------------------------------------------- /ui/src/components/common/dropdown/Dropdown.module.scss: -------------------------------------------------------------------------------- 1 | .inputContainer { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | 6 | .inputLabel { 7 | margin-bottom: 0.4rem; 8 | font-size: 0.8rem; 9 | font-weight: 300; 10 | } 11 | 12 | .dropdown { 13 | padding: 0.5rem; 14 | border: 1px solid var(--color-border-subtle); 15 | border-radius: 3px; 16 | color: var(--color-text-primary); 17 | background-color: var(--color-bg); 18 | option { 19 | color: black; 20 | background-color: white; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ui/src/components/common/dropdown/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import styles from './Dropdown.module.scss'; 2 | 3 | export type Option = { 4 | title: string; 5 | value?: string | number; 6 | }; 7 | 8 | type Props = { 9 | options: Option[]; 10 | value?: string; 11 | label?: string | null; 12 | name?: string; 13 | maxWidth?: string | number; 14 | fullWidth?: boolean; 15 | testID?: string; 16 | onChange?: (option: string) => void; 17 | }; 18 | 19 | function Dropdown({ 20 | fullWidth, 21 | name, 22 | label, 23 | options, 24 | value, 25 | maxWidth, 26 | testID, 27 | onChange, 28 | }: Props) { 29 | const renderOptions = () => { 30 | return options.map(({ title, value }) => ( 31 | 34 | )); 35 | }; 36 | 37 | return ( 38 |
39 | {label ? ( 40 | 43 | ) : ( 44 | <> 45 | )} 46 | 56 |
57 | ); 58 | } 59 | 60 | export default Dropdown; 61 | -------------------------------------------------------------------------------- /ui/src/components/common/emptyState/EmptyState.module.scss: -------------------------------------------------------------------------------- 1 | .emptyState { 2 | height: 100%; 3 | width: 100%; 4 | position: absolute; 5 | top: 0; 6 | left: 0; 7 | 8 | display: flex; 9 | flex-direction: column; 10 | justify-content: center; 11 | align-items: center; 12 | gap: 0.5rem; 13 | } 14 | 15 | .title { 16 | font-size: 1.5rem; 17 | font-weight: 600; 18 | } 19 | 20 | .subtitle { 21 | font-weight: 300; 22 | } 23 | -------------------------------------------------------------------------------- /ui/src/components/common/emptyState/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | import styles from './EmptyState.module.scss'; 3 | 4 | type Props = { 5 | title: string; 6 | subtitle?: string; 7 | }; 8 | 9 | const EmptyState = ({ 10 | title, 11 | subtitle, 12 | children, 13 | }: PropsWithChildren) => { 14 | return ( 15 |
16 | {title} 17 | {subtitle ? {subtitle} : <>} 18 | {children} 19 |
20 | ); 21 | }; 22 | 23 | export default EmptyState; 24 | -------------------------------------------------------------------------------- /ui/src/components/common/error/Error.module.scss: -------------------------------------------------------------------------------- 1 | .Error { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | height: 100%; 6 | font-size: 1.8rem; 7 | font-weight: 600; 8 | } 9 | -------------------------------------------------------------------------------- /ui/src/components/common/error/Error.tsx: -------------------------------------------------------------------------------- 1 | import styles from './Error.module.scss'; 2 | 3 | type Props = { 4 | message: string; 5 | }; 6 | 7 | function Error({ message }: Props) { 8 | return
{message}
; 9 | } 10 | 11 | export default Error; 12 | -------------------------------------------------------------------------------- /ui/src/components/common/form/Form.module.scss: -------------------------------------------------------------------------------- 1 | .Form { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 1.5rem; 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/components/common/form/Form.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | import { c } from '@/utils/classNames'; 3 | import styles from './Form.module.scss'; 4 | 5 | type Props = { 6 | maxWidth?: string | number; 7 | onSubmit?: React.FormEventHandler; 8 | className?: string; 9 | }; 10 | 11 | function Form({ 12 | children, 13 | className, 14 | maxWidth, 15 | onSubmit, 16 | }: PropsWithChildren) { 17 | return ( 18 |
23 | {children} 24 |
25 | ); 26 | } 27 | 28 | export default Form; 29 | -------------------------------------------------------------------------------- /ui/src/components/common/form/FormGroup/FormGroup.module.scss: -------------------------------------------------------------------------------- 1 | .FormGroup { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 1rem; 5 | } 6 | 7 | .heading { 8 | font-size: 1.2rem; 9 | font-weight: 600; 10 | } 11 | 12 | .content { 13 | display: flex; 14 | gap: 1rem; 15 | } 16 | 17 | .row { 18 | flex-direction: row; 19 | flex-wrap: wrap; 20 | } 21 | 22 | .column { 23 | flex-direction: column; 24 | } 25 | 26 | .info { 27 | font-size: 0.8rem; 28 | font-weight: 300; 29 | } 30 | -------------------------------------------------------------------------------- /ui/src/components/common/form/FormGroup/FormGroup.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | import { c } from '@/utils/classNames'; 3 | import styles from './FormGroup.module.scss'; 4 | 5 | type Props = { 6 | heading?: string | null; 7 | info?: string | null; 8 | direction?: 'row' | 'column'; 9 | maxWidth?: string | number; 10 | }; 11 | 12 | function FormGroup({ 13 | direction, 14 | info, 15 | heading, 16 | maxWidth, 17 | children, 18 | }: PropsWithChildren) { 19 | return ( 20 |
21 | {heading && {heading}} 22 |
31 | {children} 32 |
33 | {info && {info}} 34 |
35 | ); 36 | } 37 | 38 | export default FormGroup; 39 | -------------------------------------------------------------------------------- /ui/src/components/common/headline/Headline.module.scss: -------------------------------------------------------------------------------- 1 | .headline { 2 | margin: 0; 3 | font-weight: 500; 4 | } 5 | -------------------------------------------------------------------------------- /ui/src/components/common/headline/Headline.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | import styles from './Headline.module.scss'; 3 | 4 | const Headline = (props: PropsWithChildren) => { 5 | return

{props.children}

; 6 | }; 7 | 8 | export default Headline; 9 | -------------------------------------------------------------------------------- /ui/src/components/common/image/Image.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | function Image( 4 | props: React.DetailedHTMLProps< 5 | React.ImgHTMLAttributes, 6 | HTMLImageElement 7 | > 8 | ) { 9 | const [loaded, setLoaded] = useState(false); 10 | 11 | return ( 12 |
13 | {!loaded &&
} 14 | { 17 | setLoaded(true); 18 | }} 19 | style={loaded ? props.style : { display: 'none' }} 20 | /> 21 |
22 | ); 23 | } 24 | 25 | export default Image; 26 | -------------------------------------------------------------------------------- /ui/src/components/common/input/Input.module.scss: -------------------------------------------------------------------------------- 1 | .inputContainer { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | 6 | .innerContainer { 7 | display: flex; 8 | gap: 0.5rem; 9 | position: relative; 10 | } 11 | 12 | .icon { 13 | position: absolute; 14 | left: 0.5rem; 15 | top: 50%; 16 | transform: translateY(-50%); 17 | display: flex; 18 | align-items: center; 19 | 20 | svg { 21 | width: 1.5rem; 22 | height: 1rem; 23 | color: var(--color-text-primary); 24 | stroke: var(--color-text-primary); 25 | } 26 | } 27 | 28 | .copyButton { 29 | display: flex; 30 | justify-content: center; 31 | align-items: center; 32 | cursor: pointer; 33 | padding: 0.5rem; 34 | 35 | svg { 36 | width: 1.5rem; 37 | height: 1.5rem; 38 | 39 | path { 40 | fill: var(--color-text-primary); 41 | } 42 | } 43 | 44 | &:hover { 45 | background-color: var(--color-accent-light); 46 | border-radius: 5px; 47 | } 48 | } 49 | 50 | .inputLabel { 51 | margin-bottom: 0.4rem; 52 | font-size: 0.8rem; 53 | font-weight: 300; 54 | } 55 | 56 | .errorMessage { 57 | margin-top: 0.4rem; 58 | font-size: 0.9rem; 59 | color: var(--color-danger-bg); 60 | } 61 | 62 | .input { 63 | flex: 1; 64 | padding: 0.5rem; 65 | border: 1px solid var(--color-border-subtle); 66 | background-color: var(--color-bg); 67 | color: var(--color-text-primary); 68 | 69 | &.ellipsis { 70 | text-overflow: ellipsis; 71 | } 72 | 73 | &.error { 74 | border-color: var(--color-danger-bg); 75 | } 76 | 77 | &.disabled { 78 | background-color: var(--color-border-subtle); 79 | } 80 | 81 | &:focus { 82 | outline: var(--color-accent) solid 2px; 83 | outline-offset: 2px; 84 | } 85 | 86 | &.withIcon { 87 | padding-left: 2rem; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /ui/src/components/common/loading/Loading.module.scss: -------------------------------------------------------------------------------- 1 | .Loading { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | height: 100vh; 6 | } 7 | 8 | .ldsEllipsis { 9 | position: relative; 10 | width: 34px; 11 | height: 34px; 12 | left: -20px; 13 | } 14 | .ldsEllipsis div { 15 | top: 35%; 16 | position: absolute; 17 | width: 13px; 18 | height: 13px; 19 | border-radius: 50%; 20 | background: var(--color-text-primary); 21 | opacity: 0.8; 22 | animation-timing-function: cubic-bezier(0, 1, 1, 0); 23 | } 24 | .ldsEllipsis div:nth-child(1) { 25 | left: 8px; 26 | animation: lds-ellipsis1 0.6s infinite; 27 | } 28 | .ldsEllipsis div:nth-child(2) { 29 | left: 8px; 30 | animation: lds-ellipsis2 0.6s infinite; 31 | } 32 | .ldsEllipsis div:nth-child(3) { 33 | left: 32px; 34 | animation: lds-ellipsis2 0.6s infinite; 35 | } 36 | .ldsEllipsis div:nth-child(4) { 37 | left: 56px; 38 | animation: lds-ellipsis3 0.6s infinite; 39 | } 40 | @keyframes lds-ellipsis1 { 41 | 0% { 42 | transform: scale(0); 43 | } 44 | 100% { 45 | transform: scale(1); 46 | } 47 | } 48 | @keyframes lds-ellipsis3 { 49 | 0% { 50 | transform: scale(1); 51 | } 52 | 100% { 53 | transform: scale(0); 54 | } 55 | } 56 | @keyframes lds-ellipsis2 { 57 | 0% { 58 | transform: translate(0, 0); 59 | } 60 | 100% { 61 | transform: translate(24px, 0); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /ui/src/components/common/loading/Loading.tsx: -------------------------------------------------------------------------------- 1 | import styles from './Loading.module.scss'; 2 | 3 | function Loading() { 4 | return ( 5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | ); 14 | } 15 | 16 | export default Loading; 17 | -------------------------------------------------------------------------------- /ui/src/components/common/modal/Modal.module.scss: -------------------------------------------------------------------------------- 1 | .modalWrapper { 2 | display: none; 3 | position: fixed; 4 | z-index: 1000; 5 | left: 0; 6 | top: 0; 7 | width: 100vw; 8 | height: 100vh; 9 | background-color: rgba(0, 0, 0, 0.5); 10 | justify-content: center; 11 | align-items: center; 12 | -webkit-user-select: none; 13 | -moz-user-select: none; 14 | user-select: none; 15 | overflow: none; 16 | } 17 | 18 | .modal { 19 | background-color: var(--color-card-bg); 20 | border-radius: 5px; 21 | display: flex; 22 | flex-direction: column; 23 | align-items: center; 24 | -webkit-user-select: text; 25 | -moz-user-select: text; 26 | user-select: text; 27 | max-height: 75%; 28 | } 29 | 30 | .visible { 31 | display: flex; 32 | } 33 | 34 | @media screen and (max-width: 700px) { 35 | div.modal { 36 | width: 100%; 37 | height: 100%; 38 | max-width: 100% !important; 39 | max-height: 100% !important; 40 | } 41 | } 42 | 43 | .container { 44 | position: relative; 45 | width: 100%; 46 | height: 100%; 47 | overflow: auto; 48 | } 49 | -------------------------------------------------------------------------------- /ui/src/components/common/modal/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, useEffect, useRef } from 'react'; 2 | 3 | import ModalCloseButton from './ModalCloseButton'; 4 | import styles from './Modal.module.scss'; 5 | 6 | export type ModalProps = { 7 | visible: boolean; 8 | onClose: () => void; 9 | disableBackdropClose?: boolean; 10 | disableEscapeClose?: boolean; 11 | maxWidth?: string | number; 12 | }; 13 | 14 | export default function Modal({ 15 | onClose, 16 | ...props 17 | }: PropsWithChildren) { 18 | const ref = useRef(null); 19 | 20 | useEffect(() => { 21 | const handleKeyDown = (e: KeyboardEvent) => { 22 | if (e.key === 'Escape' && !props.disableEscapeClose) onClose(); 23 | }; 24 | 25 | document.addEventListener('keydown', handleKeyDown); 26 | 27 | return () => { 28 | document.removeEventListener('keydown', handleKeyDown); 29 | }; 30 | }, [onClose]); 31 | 32 | return ( 33 |
{ 39 | if (event.target === ref.current) { 40 | if (!props.disableBackdropClose) onClose(); 41 | } 42 | }} 43 | > 44 |
48 |
49 | 50 | {props.children} 51 |
52 |
53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /ui/src/components/common/modal/ModalCloseButton.module.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | position: absolute; 6 | padding: 0.5rem; 7 | background-color: transparent; 8 | border: none; 9 | outline: none; 10 | cursor: pointer; 11 | top: 1rem; 12 | right: 1rem; 13 | 14 | svg { 15 | width: 1.5rem; 16 | height: 1.5rem; 17 | path { 18 | stroke: var(--color-text-primary); 19 | } 20 | } 21 | 22 | &:hover { 23 | background-color: var(--color-accent-light); 24 | border-radius: 5px; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ui/src/components/common/modal/ModalCloseButton.tsx: -------------------------------------------------------------------------------- 1 | import { Close } from '@/assets'; 2 | import styles from './ModalCloseButton.module.scss'; 3 | 4 | type Props = { 5 | onClick?: () => void; 6 | }; 7 | 8 | const ModalCloseButton = ({ onClick }: Props) => { 9 | return ( 10 | 13 | ); 14 | }; 15 | 16 | export default ModalCloseButton; 17 | -------------------------------------------------------------------------------- /ui/src/components/common/paginationControls/PaginationControlButton.module.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | border: 1px solid var(--color-text-subtle); 3 | outline: none; 4 | background-color: var(--color-bg); 5 | color: var(--color-text-primary); 6 | padding: 0.5rem; 7 | cursor: pointer; 8 | font-size: 1rem; 9 | 10 | &:hover:not(&.disabled) { 11 | background-color: var(--color-accent-light); 12 | border-color: var(--color-accent); 13 | } 14 | 15 | &.disabled { 16 | cursor: not-allowed; 17 | opacity: 0.5; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ui/src/components/common/paginationControls/PaginationControlButton.tsx: -------------------------------------------------------------------------------- 1 | import { c } from '@/utils/classNames'; 2 | import styles from './PaginationControlButton.module.scss'; 3 | 4 | type Props = { 5 | disabled?: boolean; 6 | label?: string | null | undefined; 7 | onClick?: () => void; 8 | testID?: string; 9 | }; 10 | 11 | const PaginationControlButton = ({ 12 | disabled, 13 | label, 14 | onClick, 15 | testID, 16 | }: Props) => { 17 | return ( 18 | 26 | ); 27 | }; 28 | 29 | export default PaginationControlButton; 30 | -------------------------------------------------------------------------------- /ui/src/components/common/paginationControls/PaginationControls.module.scss: -------------------------------------------------------------------------------- 1 | .controls { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | gap: 1rem; 6 | } 7 | 8 | .page { 9 | border: 1px solid var(--color-text-subtle); 10 | background-color: var(--color-bg); 11 | padding: 0.5rem; 12 | text-align: center; 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/components/common/pairList/Pair/Pair.module.scss: -------------------------------------------------------------------------------- 1 | .Pair { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 0.2rem; 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/components/common/pairList/Pair/Pair.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | import styles from './Pair.module.scss'; 3 | 4 | function Pair({ children }: PropsWithChildren) { 5 | return
{children}
; 6 | } 7 | 8 | export default Pair; 9 | -------------------------------------------------------------------------------- /ui/src/components/common/pairList/PairKey/PairKey.module.scss: -------------------------------------------------------------------------------- 1 | .PairKey { 2 | flex: 1; 3 | font-weight: 300; 4 | opacity: 0.8; 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/components/common/pairList/PairKey/PairKey.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | import styles from './PairKey.module.scss'; 3 | 4 | function PairKey({ children }: PropsWithChildren) { 5 | return
{children}
; 6 | } 7 | 8 | export default PairKey; 9 | -------------------------------------------------------------------------------- /ui/src/components/common/pairList/PairList.module.scss: -------------------------------------------------------------------------------- 1 | .PairList { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 0.5rem; 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/components/common/pairList/PairList.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | import styles from './PairList.module.scss'; 3 | 4 | type Props = { 5 | maxWidth?: string | number; 6 | minWidth?: string | number; 7 | }; 8 | 9 | function PairList({ children, maxWidth, minWidth }: PropsWithChildren) { 10 | return ( 11 |
12 | {children} 13 |
14 | ); 15 | } 16 | 17 | export default PairList; 18 | -------------------------------------------------------------------------------- /ui/src/components/common/pairList/PairValue/PairValue.module.scss: -------------------------------------------------------------------------------- 1 | .PairValue { 2 | flex: 2; 3 | flex-wrap: wrap; 4 | } 5 | -------------------------------------------------------------------------------- /ui/src/components/common/pairList/PairValue/PairValue.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | import styles from './PairValue.module.scss'; 3 | 4 | function PairValue({ children }: PropsWithChildren) { 5 | return
{children}
; 6 | } 7 | 8 | export default PairValue; 9 | -------------------------------------------------------------------------------- /ui/src/components/common/table/Table.module.scss: -------------------------------------------------------------------------------- 1 | .table { 2 | width: 100%; 3 | border-collapse: collapse; 4 | border-radius: 0.5rem 0.5rem 0 0; 5 | overflow: hidden; 6 | } 7 | -------------------------------------------------------------------------------- /ui/src/components/common/table/Table.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentPropsWithoutRef } from 'react'; 2 | import { c } from '@/utils/classNames'; 3 | import styles from './Table.module.scss'; 4 | 5 | export default function Table({ 6 | children, 7 | className, 8 | ...props 9 | }: ComponentPropsWithoutRef<'table'>) { 10 | return ( 11 | 12 | {children} 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /ui/src/components/common/table/TableBody.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentPropsWithoutRef } from 'react'; 2 | 3 | export default function TableBody({ 4 | children, 5 | ...props 6 | }: ComponentPropsWithoutRef<'tbody'>) { 7 | return {children}; 8 | } 9 | -------------------------------------------------------------------------------- /ui/src/components/common/table/TableCell.module.scss: -------------------------------------------------------------------------------- 1 | .cell { 2 | text-align: left; 3 | padding: 1rem 1.5rem; 4 | white-space: nowrap; 5 | font-size: 0.9rem; 6 | } 7 | 8 | @media screen and (max-width: 640px) { 9 | .cell { 10 | padding: 1rem; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /ui/src/components/common/table/TableCell.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentPropsWithoutRef } from 'react'; 2 | import { c } from '@/utils/classNames'; 3 | import styles from './TableCell.module.scss'; 4 | 5 | export default function TableCell({ 6 | children, 7 | className, 8 | ...props 9 | }: ComponentPropsWithoutRef<'td'>) { 10 | return ( 11 | 12 | {children} 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /ui/src/components/common/table/TableHead.module.scss: -------------------------------------------------------------------------------- 1 | .tableHead { 2 | background-color: var(--color-border-subtle); 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/components/common/table/TableHead.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentPropsWithoutRef } from 'react'; 2 | import { c } from '@/utils/classNames'; 3 | import styles from './TableHead.module.scss'; 4 | 5 | export default function TableHead({ 6 | children, 7 | ...props 8 | }: ComponentPropsWithoutRef<'thead'>) { 9 | return ( 10 | 11 | {children} 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /ui/src/components/common/table/TableHeadCell.module.scss: -------------------------------------------------------------------------------- 1 | .tableHeadCell { 2 | font-weight: 600; 3 | font-size: 0.9rem; 4 | text-align: left; 5 | padding: 0.5rem 1.5rem; 6 | white-space: nowrap; 7 | text-transform: uppercase; 8 | color: var(--color-text-subtle); 9 | } 10 | 11 | @media screen and (max-width: 640px) { 12 | .tableHeadCell { 13 | padding: 1rem; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /ui/src/components/common/table/TableHeadCell.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentPropsWithoutRef } from 'react'; 2 | import { c } from '@/utils/classNames'; 3 | import styles from './TableHeadCell.module.scss'; 4 | 5 | export default function TableHeadCell({ 6 | children, 7 | className, 8 | ...props 9 | }: ComponentPropsWithoutRef<'th'>) { 10 | return ( 11 | 12 | {children} 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /ui/src/components/common/table/TableRow.module.scss: -------------------------------------------------------------------------------- 1 | tbody tr:nth-child(even) { 2 | background-color: var(--color-card-bg); 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/components/common/table/TableRow.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentPropsWithoutRef } from 'react'; 2 | import { c } from '@/utils/classNames'; 3 | import styles from './TableRow.module.scss'; 4 | 5 | export default function TableRow({ 6 | children, 7 | ...props 8 | }: ComponentPropsWithoutRef<'tr'>) { 9 | return ( 10 | 11 | {children} 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /ui/src/components/epg/event/channelInfo/EventChannelInfo.module.scss: -------------------------------------------------------------------------------- 1 | .EventChannelInfo { 2 | display: flex; 3 | justify-content: space-between; 4 | } 5 | 6 | .channelName { 7 | font-weight: 400; 8 | opacity: 0.8; 9 | } 10 | 11 | .picon { 12 | height: 3rem; 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/components/epg/event/channelInfo/EventChannelInfo.tsx: -------------------------------------------------------------------------------- 1 | import Image from '@/components/common/image/Image'; 2 | import styles from './EventChannelInfo.module.scss'; 3 | 4 | type Props = { 5 | channelName: string; 6 | picon: string; 7 | }; 8 | 9 | function EventChannelInfo({ channelName, picon }: Props) { 10 | return ( 11 |
12 | {channelName} 13 | 14 |
15 | ); 16 | } 17 | 18 | export default EventChannelInfo; 19 | -------------------------------------------------------------------------------- /ui/src/components/epg/event/info/EventInfo.module.scss: -------------------------------------------------------------------------------- 1 | .EventInfo { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 1rem; 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/components/epg/event/info/EventInfo.tsx: -------------------------------------------------------------------------------- 1 | import { EpgEvent } from '@/clients/api/api.types'; 2 | import EventRecordButton from '../recordButton/EventRecordButton'; 3 | import Pair from '@/components/common/pairList/Pair/Pair'; 4 | import PairKey from '@/components/common/pairList/PairKey/PairKey'; 5 | import PairList from '@/components/common/pairList/PairList'; 6 | import PairValue from '@/components/common/pairList/PairValue/PairValue'; 7 | import styles from './EventInfo.module.scss'; 8 | import { useTranslation } from 'react-i18next'; 9 | 10 | type Props = { 11 | event: EpgEvent; 12 | pending: boolean; 13 | handleOnRecord: () => void; 14 | }; 15 | 16 | function EventInfo({ event, handleOnRecord, pending }: Props) { 17 | const { t } = useTranslation(); 18 | 19 | return ( 20 |
21 |

{event.title}

22 |
23 | 28 |
29 | 30 | 31 | {t('subtitle')} 32 | {event.subtitle} 33 | 34 | 35 | {t('airs')} 36 | {t('event_datetime', { event })} 37 | 38 | 39 | {t('description')} 40 | {event.description} 41 | 42 | 43 |
44 | ); 45 | } 46 | 47 | export default EventInfo; 48 | -------------------------------------------------------------------------------- /ui/src/components/epg/event/recordButton/EventRecordButton.module.scss: -------------------------------------------------------------------------------- 1 | .EventRecordButton { 2 | display: flex; 3 | align-items: center; 4 | gap: 0.5rem; 5 | flex: 1; 6 | border: 1px solid var(--color-danger-bg); 7 | padding: 0.5rem; 8 | border-radius: 2px; 9 | background-color: var(--color-danger-bg); 10 | color: white; 11 | cursor: pointer; 12 | font-weight: 600; 13 | 14 | &:hover { 15 | opacity: 0.8; 16 | } 17 | 18 | &.pending { 19 | opacity: 0.5; 20 | cursor: not-allowed; 21 | } 22 | } 23 | 24 | .icon { 25 | width: 1rem; 26 | height: 1rem; 27 | fill: var(--color-text-primary); 28 | } 29 | -------------------------------------------------------------------------------- /ui/src/components/epg/event/recordButton/EventRecordButton.tsx: -------------------------------------------------------------------------------- 1 | import { RecIcon } from '@/assets'; 2 | import { c } from '@/utils/classNames'; 3 | import styles from './EventRecordButton.module.scss'; 4 | import { useTranslation } from 'react-i18next'; 5 | 6 | type Props = { 7 | dvrUuid?: string; 8 | pending?: boolean; 9 | onClick: () => void; 10 | }; 11 | 12 | function EventRecordButton({ dvrUuid, pending, onClick }: Props) { 13 | const { t } = useTranslation(); 14 | 15 | const getText = () => { 16 | if (dvrUuid) { 17 | return t('modify_recording'); 18 | } 19 | return t('record'); 20 | }; 21 | 22 | return ( 23 | 30 | ); 31 | } 32 | 33 | export default EventRecordButton; 34 | -------------------------------------------------------------------------------- /ui/src/components/epg/event/related/EventRelated.module.scss: -------------------------------------------------------------------------------- 1 | .EventRelated { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 0.4rem; 5 | } 6 | 7 | .link { 8 | color: var(--color-text-primary); 9 | text-decoration: none; 10 | &:hover { 11 | text-decoration: underline; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/components/epg/event/related/EventRelated.tsx: -------------------------------------------------------------------------------- 1 | import { EpgEvent } from '@/clients/api/api.types'; 2 | import { Link } from 'react-router-dom'; 3 | import Pair from '@/components/common/pairList/Pair/Pair'; 4 | import PairKey from '@/components/common/pairList/PairKey/PairKey'; 5 | import PairList from '@/components/common/pairList/PairList'; 6 | import PairValue from '@/components/common/pairList/PairValue/PairValue'; 7 | import styles from './EventRelated.module.scss'; 8 | import { useTranslation } from 'react-i18next'; 9 | 10 | type Props = { 11 | relatedEvents: EpgEvent[]; 12 | }; 13 | 14 | function EventRelated({ relatedEvents }: Props) { 15 | const { t } = useTranslation(); 16 | 17 | const renderTitle = (event: EpgEvent) => { 18 | const datetime = t('event_datetime', { event }); 19 | const subtitle = event.subtitle ? ` • ${event.subtitle}` : ''; 20 | return `${datetime}${subtitle}`; 21 | }; 22 | 23 | const renderRelatedEvents = () => { 24 | return relatedEvents.map((event) => { 25 | return ( 26 | 27 | 28 | {event.channelName} 29 | 30 | 31 | {renderTitle(event)} 32 | 33 | 34 | 35 | 36 | ); 37 | }); 38 | }; 39 | 40 | if (relatedEvents.length === 0) { 41 | return <>; 42 | } 43 | 44 | return ( 45 |
46 |

{t('related_events')}

47 | {renderRelatedEvents()} 48 |
49 | ); 50 | } 51 | 52 | export default EventRelated; 53 | -------------------------------------------------------------------------------- /ui/src/components/epg/guide/channel/GuideChannel.module.scss: -------------------------------------------------------------------------------- 1 | .number { 2 | font-size: 1rem; 3 | font-weight: 400; 4 | opacity: 0.8; 5 | } 6 | 7 | .container { 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | overflow: hidden; 12 | padding-bottom: 1rem; 13 | } 14 | 15 | .picon { 16 | height: 3rem; 17 | } 18 | 19 | .channel { 20 | display: flex; 21 | flex-direction: column; 22 | justify-content: center; 23 | align-items: center; 24 | padding: 1rem; 25 | gap: 0.5rem; 26 | cursor: pointer; 27 | 28 | &:hover { 29 | background-color: var(--color-accent-light); 30 | border-radius: 5px; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /ui/src/components/epg/guide/channel/GuideChannel.tsx: -------------------------------------------------------------------------------- 1 | import Image from '@/components/common/image/Image'; 2 | import styles from './GuideChannel.module.scss'; 3 | 4 | type Props = { 5 | name: string; 6 | picon: string; 7 | number: number; 8 | onClick: () => void; 9 | }; 10 | 11 | function GuideChannel({ name, picon, number, onClick }: Props) { 12 | return ( 13 |
14 |
15 | 16 | {number} 17 |
18 |
19 | ); 20 | } 21 | 22 | export default GuideChannel; 23 | -------------------------------------------------------------------------------- /ui/src/components/epg/guide/controls/GuideControls.module.scss: -------------------------------------------------------------------------------- 1 | .controls { 2 | display: flex; 3 | flex-wrap: wrap; 4 | gap: 1rem; 5 | 6 | * { 7 | flex: 1; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ui/src/components/epg/guide/controls/GuideControls.tsx: -------------------------------------------------------------------------------- 1 | import Dropdown, { Option } from '@/components/common/dropdown/Dropdown'; 2 | 3 | import Input from '@/components/common/input/Input'; 4 | import moment from 'moment'; 5 | import styles from './GuideControls.module.scss'; 6 | import { useTranslation } from 'react-i18next'; 7 | 8 | type Props = { 9 | onSearch: (q: string) => void; 10 | onDayChange: (day: string) => void; 11 | search: string; 12 | day: string; 13 | }; 14 | 15 | function GuideControls({ day, search, onSearch, onDayChange }: Props) { 16 | const { t } = useTranslation(); 17 | 18 | const getDays = () => { 19 | const days: Option[] = [ 20 | { 21 | title: t('today'), 22 | value: 'today', 23 | }, 24 | ]; 25 | 26 | for (let i = 1; i < 7; i++) { 27 | const date = moment().add(i, 'day').startOf('day'); 28 | 29 | const title = `${t(`weekday_${date.day()}`)} (${t('short_date', { 30 | ts: date.unix(), 31 | })})`; 32 | 33 | days.push({ 34 | title, 35 | value: date.unix(), 36 | }); 37 | } 38 | return days; 39 | }; 40 | 41 | return ( 42 |
43 | 44 | { 48 | onSearch(e.target.value); 49 | }} 50 | /> 51 |
52 | ); 53 | } 54 | 55 | export default GuideControls; 56 | -------------------------------------------------------------------------------- /ui/src/components/epg/guide/event/GuideEvent.module.scss: -------------------------------------------------------------------------------- 1 | .event { 2 | display: flex; 3 | flex-direction: column; 4 | padding: 1rem; 5 | background-color: var(--color-card-bg); 6 | cursor: pointer; 7 | position: relative; 8 | 9 | &:hover { 10 | background-color: var(--color-text-subtle); 11 | } 12 | } 13 | 14 | .progress { 15 | position: absolute; 16 | bottom: 0; 17 | left: 0; 18 | height: 0.1rem; 19 | background-color: aqua; 20 | } 21 | 22 | .attribute { 23 | overflow: hidden; 24 | white-space: nowrap; 25 | text-overflow: ellipsis; 26 | } 27 | 28 | .name { 29 | font-size: 1rem; 30 | font-weight: 600; 31 | margin-bottom: 1rem; 32 | } 33 | 34 | .subtitle { 35 | height: 1.5rem; 36 | font-size: 0.8rem; 37 | font-weight: 400; 38 | opacity: 0.8; 39 | } 40 | 41 | .time { 42 | font-size: 0.8rem; 43 | font-weight: 400; 44 | opacity: 0.8; 45 | } 46 | 47 | .channel { 48 | font-size: 0.8rem; 49 | font-weight: 400; 50 | opacity: 0.8; 51 | } 52 | 53 | .recBadge { 54 | position: absolute; 55 | background-color: var(--color-danger-bg); 56 | bottom: 0.5rem; 57 | right: 0.5rem; 58 | width: 0.7rem; 59 | height: 0.7rem; 60 | border-radius: 90px; 61 | display: flex; 62 | justify-content: center; 63 | align-items: center; 64 | } 65 | -------------------------------------------------------------------------------- /ui/src/components/epg/guide/eventColumn/GuideEventColumn.module.scss: -------------------------------------------------------------------------------- 1 | .column { 2 | display: flex; 3 | flex-direction: column; 4 | overflow: hidden; 5 | padding: 1rem 0; 6 | gap: 2rem; 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/components/epg/guide/eventColumn/GuideEventColumn.tsx: -------------------------------------------------------------------------------- 1 | import { EpgEvent } from '@/clients/api/api.types'; 2 | import GuideEvent from '../event/GuideEvent'; 3 | import styles from './GuideEventColumn.module.scss'; 4 | 5 | type Props = { 6 | events: EpgEvent[]; 7 | onClick: (eventId: number) => void; 8 | }; 9 | 10 | function GuideEventColumn({ events, onClick }: Props) { 11 | const renderEvents = () => { 12 | return events.map((event, index) => ( 13 | 25 | )); 26 | }; 27 | 28 | return
{renderEvents()}
; 29 | } 30 | 31 | export default GuideEventColumn; 32 | -------------------------------------------------------------------------------- /ui/src/components/epg/guide/navigation/GuideNavigation.module.scss: -------------------------------------------------------------------------------- 1 | .navigation { 2 | position: absolute; 3 | width: 3rem; 4 | height: 3rem; 5 | stroke: var(--color-text-primary); 6 | cursor: pointer; 7 | -webkit-user-select: none; 8 | -moz-user-select: none; 9 | user-select: none; 10 | 11 | &:hover { 12 | stroke: var(--color-accent); 13 | } 14 | } 15 | 16 | .left { 17 | left: -1rem; 18 | top: 30%; 19 | } 20 | 21 | .right { 22 | right: -1rem; 23 | top: 30%; 24 | } 25 | -------------------------------------------------------------------------------- /ui/src/components/epg/guide/navigation/GuideNavigation.tsx: -------------------------------------------------------------------------------- 1 | import { LargeArrowLeftIcon, LargeArrowRightIcon } from '@/assets'; 2 | 3 | import { c } from '@/utils/classNames'; 4 | import styles from './GuideNavigation.module.scss'; 5 | 6 | type Props = { 7 | type?: 'left' | 'right'; 8 | onClick: () => void; 9 | }; 10 | 11 | function GuideNavigation({ type, onClick }: Props) { 12 | if (type === 'left') { 13 | return ( 14 | 19 | ); 20 | } 21 | 22 | return ( 23 | 28 | ); 29 | } 30 | 31 | export default GuideNavigation; 32 | -------------------------------------------------------------------------------- /ui/src/components/header/Header.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | height: 100%; 3 | } 4 | 5 | .bar { 6 | position: fixed; 7 | display: flex; 8 | align-items: center; 9 | z-index: 100; 10 | height: 3.5rem; 11 | width: 100%; 12 | background-color: var(--color-card-bg); 13 | border-bottom: 1px solid var(--color-border-subtle); 14 | box-sizing: border-box; 15 | padding: 0 2rem; 16 | } 17 | 18 | .left { 19 | display: flex; 20 | align-items: center; 21 | gap: 1rem; 22 | flex-shrink: 0; 23 | } 24 | 25 | .right { 26 | display: flex; 27 | align-items: center; 28 | gap: 1rem; 29 | margin-left: auto; 30 | } 31 | 32 | .logo { 33 | max-width: 5rem; 34 | height: 2.5rem; 35 | margin-bottom: 0.5em; 36 | } 37 | 38 | .menuIcon { 39 | display: block; 40 | fill: var(--color-text-primary); 41 | height: 1rem; 42 | width: 1rem; 43 | } 44 | 45 | @media screen and (min-width: 700px) { 46 | .menuIcon { 47 | display: none; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /ui/src/components/header/Header.tsx: -------------------------------------------------------------------------------- 1 | import { BurgerMenuIcon, SearchIcon, TvhgoHorizontalLogo } from '@/assets'; 2 | import { useMatch, useNavigate, useSearchParams } from 'react-router-dom'; 3 | 4 | import Input from '../common/input/Input'; 5 | import styles from './Header.module.scss'; 6 | import { useFormik } from 'formik'; 7 | import { useTranslation } from 'react-i18next'; 8 | 9 | type Props = { 10 | onToggle: () => void; 11 | }; 12 | 13 | function Header({ onToggle }: Props) { 14 | const { t } = useTranslation(); 15 | const navigate = useNavigate(); 16 | const isSearch = useMatch(`/search`); 17 | const [searchParams] = useSearchParams(); 18 | 19 | const searchForm = useFormik({ 20 | initialValues: { 21 | query: isSearch ? searchParams.get('q') || '' : '', 22 | }, 23 | onSubmit: ({ query }) => navigate(`/search?q=${encodeURIComponent(query)}`), 24 | enableReinitialize: true, 25 | }); 26 | 27 | const renderSearch = () => { 28 | return ( 29 |
30 | } 33 | placeholder={t('search')} 34 | value={searchForm.values.query} 35 | onChange={searchForm.handleChange} 36 | onBlur={searchForm.handleBlur} 37 | /> 38 |
39 | ); 40 | }; 41 | 42 | return ( 43 |
44 |
45 |
46 | onToggle()} 49 | /> 50 | 51 |
52 |
{renderSearch()}
53 |
54 |
55 | ); 56 | } 57 | 58 | export default Header; 59 | -------------------------------------------------------------------------------- /ui/src/components/login/card/LoginCard.module.scss: -------------------------------------------------------------------------------- 1 | .card { 2 | border-radius: 2px; 3 | background-color: var(--color-card-bg); 4 | padding: 3rem; 5 | max-width: 22rem; 6 | flex: 1; 7 | margin: 2rem; 8 | gap: 2rem; 9 | 10 | display: flex; 11 | flex-direction: column; 12 | } 13 | 14 | .imageHeader { 15 | display: flex; 16 | justify-content: center; 17 | align-items: center; 18 | 19 | .image { 20 | max-height: 12rem; 21 | } 22 | } 23 | 24 | .form { 25 | display: flex; 26 | flex-direction: column; 27 | gap: 1rem; 28 | } 29 | -------------------------------------------------------------------------------- /ui/src/components/login/card/LoginCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren } from 'react'; 2 | 3 | import styles from './LoginCard.module.scss'; 4 | 5 | type Props = { 6 | title?: string; 7 | onSubmit?: React.FormEventHandler; 8 | }; 9 | 10 | function LoginCard(props: PropsWithChildren) { 11 | return ( 12 |
13 |
14 | 15 |
16 |
21 | {props.children} 22 |
23 |
24 | ); 25 | } 26 | 27 | export default LoginCard; 28 | -------------------------------------------------------------------------------- /ui/src/components/login/footer/LoginFooter.module.scss: -------------------------------------------------------------------------------- 1 | .footer { 2 | position: absolute; 3 | bottom: 1rem; 4 | padding: 1rem 0; 5 | font-size: 12px; 6 | } 7 | 8 | .sep { 9 | margin: 0 0.5rem; 10 | } 11 | 12 | .link { 13 | color: var(--color-text-primary); 14 | text-decoration: none; 15 | 16 | &:hover { 17 | text-decoration: underline; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ui/src/components/login/footer/LoginFooter.tsx: -------------------------------------------------------------------------------- 1 | import styles from './LoginFooter.module.scss'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | type Props = { 5 | commitHash: string; 6 | githubUrl: string; 7 | version: string; 8 | }; 9 | 10 | function LoginFooter(props: Props) { 11 | const { t } = useTranslation(); 12 | 13 | return ( 14 | 39 | ); 40 | } 41 | 42 | export default LoginFooter; 43 | -------------------------------------------------------------------------------- /ui/src/components/navigation/bar/NavigationBar.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | background-color: var(--color-card-bg); 3 | border-right: 1px solid var(--color-border-subtle); 4 | width: 100%; 5 | height: 100%; 6 | display: flex; 7 | flex-direction: column; 8 | } 9 | 10 | .head { 11 | margin: 0 auto; 12 | padding: 0.5rem 0; 13 | 14 | .logo { 15 | max-width: 6rem; 16 | } 17 | } 18 | 19 | .items { 20 | display: flex; 21 | flex-direction: column; 22 | } 23 | 24 | @media screen and (max-width: 700px) { 25 | .root { 26 | position: absolute; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ui/src/components/navigation/bar/NavigationBar.tsx: -------------------------------------------------------------------------------- 1 | import { INavigationItem } from '../types'; 2 | import NavigationItem from '../item/NavigationItem'; 3 | import styles from './NavigationBar.module.scss'; 4 | 5 | type Props = { 6 | items: INavigationItem[]; 7 | roles?: string[]; 8 | }; 9 | 10 | function NavigationBar({ items, roles = [] }: Props) { 11 | return ( 12 |
13 |
14 | {items.map(({ icon, title, to, items: children, requiredRoles }) => ( 15 | 25 | ))} 26 |
27 |
28 | ); 29 | } 30 | 31 | export default NavigationBar; 32 | -------------------------------------------------------------------------------- /ui/src/components/navigation/item/NavigationItem.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | position: relative; 3 | } 4 | 5 | .container.active.topLevel::before { 6 | width: 0.2rem; 7 | height: 100%; 8 | background-color: var(--color-nav-accent); 9 | position: absolute; 10 | top: 0; 11 | left: 0; 12 | content: ''; 13 | } 14 | 15 | .root { 16 | padding: 0.8rem 0; 17 | padding-left: 2rem; 18 | padding-right: 1rem; 19 | color: var(--color-text-primary); 20 | text-decoration: none; 21 | box-sizing: border-box; 22 | display: flex; 23 | align-items: center; 24 | 25 | span { 26 | overflow: hidden; 27 | text-overflow: ellipsis; 28 | white-space: nowrap; 29 | } 30 | 31 | svg { 32 | flex-shrink: 0; 33 | width: 1rem; 34 | height: 1rem; 35 | margin-right: 0.9rem; 36 | fill: var(--color-text-primary); 37 | } 38 | 39 | &.topLevel.active { 40 | background-color: var(--color-border-subtle); 41 | } 42 | 43 | &.active { 44 | svg { 45 | fill: var(--color-nav-accent); 46 | } 47 | 48 | span { 49 | color: var(--color-nav-accent); 50 | } 51 | } 52 | 53 | &:hover { 54 | svg { 55 | fill: var(--color-nav-accent); 56 | } 57 | 58 | span { 59 | color: var(--color-nav-accent); 60 | } 61 | } 62 | } 63 | 64 | .subItems { 65 | padding-left: 2rem; 66 | } 67 | -------------------------------------------------------------------------------- /ui/src/components/navigation/item/NavigationItem.tsx: -------------------------------------------------------------------------------- 1 | import { NavLink, useMatch } from 'react-router-dom'; 2 | 3 | import { INavigationItem } from '../types'; 4 | import { ReactElement } from 'react'; 5 | import { c } from '@/utils/classNames'; 6 | import styles from './NavigationItem.module.scss'; 7 | 8 | type Props = { 9 | to: string; 10 | icon?: ReactElement; 11 | title: string; 12 | items?: INavigationItem[]; 13 | topLevel?: boolean; 14 | requiredRoles?: string[]; 15 | roles?: string[]; 16 | }; 17 | 18 | function NavigationItem({ 19 | to, 20 | icon, 21 | title, 22 | items, 23 | topLevel, 24 | requiredRoles = [], 25 | roles = [], 26 | }: Props) { 27 | const match = useMatch(`${to}/` + `*`); 28 | 29 | if ( 30 | requiredRoles.length > 0 && 31 | !requiredRoles.some((role) => roles.includes(role)) 32 | ) { 33 | return null; 34 | } 35 | 36 | return ( 37 |
44 | 46 | c( 47 | styles.root, 48 | isActive ? styles.active : '', 49 | topLevel ? styles.topLevel : '' 50 | ) 51 | } 52 | to={to} 53 | > 54 | {icon || null} 55 | {title} 56 | 57 | 58 | {items && match && ( 59 |
60 | {items.map(({ icon, title, to, requiredRoles }) => ( 61 | 68 | ))} 69 |
70 | )} 71 |
72 | ); 73 | } 74 | 75 | export default NavigationItem; 76 | -------------------------------------------------------------------------------- /ui/src/components/navigation/types.ts: -------------------------------------------------------------------------------- 1 | import { ReactElement } from 'react'; 2 | 3 | export type INavigationItem = { 4 | to: string; 5 | title: string; 6 | requiredRoles?: string[]; 7 | icon?: ReactElement; 8 | items?: INavigationItem[]; 9 | }; 10 | -------------------------------------------------------------------------------- /ui/src/components/recordings/listItem/RecordingListItem.module.scss: -------------------------------------------------------------------------------- 1 | .RecordingListItem { 2 | display: flex; 3 | background-color: var(--color-card-bg); 4 | border-radius: 2px; 5 | padding: 0.5rem; 6 | justify-content: space-between; 7 | align-items: center; 8 | gap: 1rem; 9 | } 10 | 11 | .link { 12 | cursor: pointer; 13 | flex: 1; 14 | overflow: hidden; 15 | text-overflow: ellipsis; 16 | white-space: nowrap; 17 | } 18 | 19 | .title { 20 | font-weight: 600; 21 | } 22 | 23 | .secondary { 24 | display: flex; 25 | align-items: center; 26 | gap: 0.5rem; 27 | font-size: 0.8rem; 28 | opacity: 0.8; 29 | font-weight: 400; 30 | } 31 | 32 | .recIndicator { 33 | background-color: var(--color-danger-bg); 34 | width: 0.7rem; 35 | height: 0.7rem; 36 | border-radius: 90px; 37 | } 38 | -------------------------------------------------------------------------------- /ui/src/components/recordings/listItem/RecordingListItem.tsx: -------------------------------------------------------------------------------- 1 | import Checkbox from '@/components/common/checkbox/Checkbox'; 2 | import { Recording } from '@/clients/api/api.types'; 3 | import { TestIds } from '@/__test__/ids'; 4 | import styles from './RecordingListItem.module.scss'; 5 | import { useTranslation } from 'react-i18next'; 6 | 7 | type Props = { 8 | recording: Recording; 9 | onClick: () => void; 10 | onSelection: (selected: boolean) => void; 11 | selected: boolean; 12 | }; 13 | 14 | function renderTitle(title: string, subtitle?: string) { 15 | return `${title}${subtitle ? ` (${subtitle})` : ''}`; 16 | } 17 | 18 | function RecordingListItem({ 19 | recording, 20 | selected, 21 | onClick, 22 | onSelection, 23 | }: Props) { 24 | const { t } = useTranslation(); 25 | 26 | const renderRecIndicator = () => { 27 | if (recording.status === 'recording') { 28 | return ( 29 |
33 | ); 34 | } 35 | }; 36 | 37 | return ( 38 |
39 |
40 | 41 | {renderTitle(recording.title, recording.subtitle)} 42 | 43 |
44 | {renderRecIndicator()} 45 | 46 | {recording.channelName} |{' '} 47 | {t('event_datetime', { event: recording })} 48 | 49 |
50 |
51 | 52 | onSelection(checked)} 54 | checked={selected} 55 | testId={TestIds.SELECT_RECORDING_CHECKBOX} 56 | /> 57 |
58 | ); 59 | } 60 | 61 | export default RecordingListItem; 62 | -------------------------------------------------------------------------------- /ui/src/components/settings/sessionList/SessionList.module.scss: -------------------------------------------------------------------------------- 1 | .SessionList { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 1rem; 5 | } 6 | 7 | .table { 8 | width: min-content; 9 | } 10 | 11 | .sessionDeleteButton { 12 | path { 13 | stroke: white; 14 | } 15 | } 16 | 17 | @media screen and (max-width: 1024px) { 18 | .created { 19 | display: none; 20 | } 21 | } 22 | 23 | @media screen and (max-width: 768px) { 24 | .ip { 25 | display: none; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /ui/src/components/settings/tokenList/TokenList.module.scss: -------------------------------------------------------------------------------- 1 | .TokenList { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 1rem; 5 | } 6 | 7 | .table { 8 | width: min-content; 9 | } 10 | 11 | .deleteButton { 12 | path { 13 | stroke: white; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /ui/src/components/settings/twoFactorAuthSettings/TwoFactorAuthSettingsOverview.module.scss: -------------------------------------------------------------------------------- 1 | .settings { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 1rem; 5 | align-items: flex-start; 6 | } 7 | -------------------------------------------------------------------------------- /ui/src/components/settings/twoFactorAuthSettings/TwoFactorAuthSettingsOverview.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@/components/common/button/Button'; 2 | import Headline from '@/components/common/headline/Headline'; 3 | import { TestIds } from '@/__test__/ids'; 4 | import { TwoFactorAuthSettings } from '@/clients/api/api.types'; 5 | import styles from './TwoFactorAuthSettingsOverview.module.scss'; 6 | import { useTranslation } from 'react-i18next'; 7 | 8 | type Props = { 9 | settings: TwoFactorAuthSettings | null; 10 | onDisable: () => void; 11 | onEnable: () => void; 12 | }; 13 | 14 | const TwoFactorAuthSettingsOverview = ({ 15 | settings, 16 | onDisable, 17 | onEnable, 18 | }: Props) => { 19 | const { t } = useTranslation(); 20 | 21 | return ( 22 |
23 | {t('two_factor_auth')} 24 | {settings?.enabled ? ( 25 |
39 | ); 40 | }; 41 | 42 | export default TwoFactorAuthSettingsOverview; 43 | -------------------------------------------------------------------------------- /ui/src/contexts/AuthContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | 3 | import { UserResponse } from '@/clients/api/api.types'; 4 | 5 | export type AuthContextProps = { 6 | user: UserResponse | null; 7 | setUser: (user: UserResponse | null) => void; 8 | }; 9 | 10 | export const AuthContext = createContext({ 11 | user: null, 12 | setUser: () => { 13 | throw new Error('not implemented'); 14 | }, 15 | }); 16 | 17 | export const useAuth = () => { 18 | return useContext(AuthContext); 19 | }; 20 | -------------------------------------------------------------------------------- /ui/src/contexts/LoadingContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | 3 | export type LoadingContextProps = { 4 | isLoading: boolean; 5 | setIsLoading: (isLoading: boolean) => void; 6 | }; 7 | 8 | export const LoadingContext = createContext({ 9 | isLoading: false, 10 | setIsLoading: () => { 11 | throw new Error('not implemented'); 12 | }, 13 | }); 14 | 15 | export const useLoading = () => { 16 | return useContext(LoadingContext); 17 | }; 18 | -------------------------------------------------------------------------------- /ui/src/contexts/ThemeContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | 3 | export enum Theme { 4 | LIGHT = 'light', 5 | DARK = 'dark', 6 | } 7 | 8 | type ThemeContextProps = { 9 | theme: Theme; 10 | setTheme: (theme: Theme) => void; 11 | }; 12 | 13 | export const ThemeContext = createContext({ 14 | theme: Theme.DARK, 15 | setTheme: () => { 16 | throw new Error('not implemented'); 17 | }, 18 | }); 19 | 20 | export const useTheme = () => { 21 | return useContext(ThemeContext); 22 | }; 23 | -------------------------------------------------------------------------------- /ui/src/hooks/dvr.ts: -------------------------------------------------------------------------------- 1 | import { deleteDVRConfig } from '@/clients/api/api'; 2 | import { useNotification } from './notification'; 3 | import { useRevalidator } from 'react-router-dom'; 4 | import { useState } from 'react'; 5 | import { useTranslation } from 'react-i18next'; 6 | 7 | export const useDVRConfig = (): [boolean, (id: string) => Promise] => { 8 | const { notifyError, notifySuccess, dismissNotification } = 9 | useNotification('dvrConfig'); 10 | const [isPending, setIsPending] = useState(false); 11 | const { revalidate } = useRevalidator(); 12 | 13 | const { t } = useTranslation(); 14 | 15 | const _deleteDVRConfig = async (id: string) => { 16 | dismissNotification(); 17 | setIsPending(true); 18 | return deleteDVRConfig(id) 19 | .then(() => { 20 | notifySuccess(t('dvr_config_deleted')); 21 | revalidate(); 22 | }) 23 | .catch(() => { 24 | notifyError(t('unexpected')); 25 | }) 26 | .finally(() => setIsPending(false)); 27 | }; 28 | 29 | return [isPending, _deleteDVRConfig]; 30 | }; 31 | -------------------------------------------------------------------------------- /ui/src/hooks/formik.ts: -------------------------------------------------------------------------------- 1 | import { FormikValues, useFormik } from 'formik'; 2 | import { RefObject, useEffect } from 'react'; 3 | 4 | const useFormikErrorFocus = ( 5 | { isSubmitting, errors }: ReturnType>, 6 | ...refs: RefObject[] 7 | ) => { 8 | useEffect(() => { 9 | if (isSubmitting) { 10 | for (const [field, error] of Object.entries(errors)) { 11 | const ref = refs.find((ref) => ref.current?.name === field); 12 | 13 | if (ref && error) { 14 | ref.current?.focus(); 15 | return; 16 | } 17 | } 18 | } 19 | }, [isSubmitting, errors, refs]); 20 | }; 21 | 22 | export default useFormikErrorFocus; 23 | -------------------------------------------------------------------------------- /ui/src/hooks/logout.ts: -------------------------------------------------------------------------------- 1 | import { logout } from '@/clients/api/api'; 2 | import { useAuth } from '@/contexts/AuthContext'; 3 | import { useLoading } from '@/contexts/LoadingContext'; 4 | import { useState } from 'react'; 5 | import { useTranslation } from 'react-i18next'; 6 | 7 | const useLogout = () => { 8 | const { t } = useTranslation(); 9 | const authContext = useAuth(); 10 | const { setIsLoading } = useLoading(); 11 | const [error, setError] = useState(null); 12 | 13 | const _logout = () => { 14 | setIsLoading(true); 15 | logout() 16 | .catch(() => { 17 | setError(t('unexpected')); 18 | }) 19 | .then(() => authContext.setUser(null)) 20 | .finally(() => setIsLoading(false)); 21 | }; 22 | 23 | return { logout: _logout, error }; 24 | }; 25 | 26 | export default useLogout; 27 | -------------------------------------------------------------------------------- /ui/src/hooks/notification.ts: -------------------------------------------------------------------------------- 1 | import { toast } from 'react-toastify'; 2 | import { useCallback } from 'react'; 3 | 4 | export const useNotification = (notificationId: string) => { 5 | const notifyError = useCallback( 6 | (message?: string | null) => { 7 | toast.error(message, { 8 | toastId: notificationId, 9 | updateId: notificationId, 10 | }); 11 | }, 12 | [notificationId] 13 | ); 14 | 15 | const notifySuccess = useCallback( 16 | (message?: string | null) => { 17 | toast.success(message, { 18 | toastId: notificationId, 19 | updateId: notificationId, 20 | }); 21 | }, 22 | [notificationId] 23 | ); 24 | 25 | const dismissNotification = () => toast.dismiss(notificationId); 26 | 27 | return { notifyError, notifySuccess, dismissNotification }; 28 | }; 29 | -------------------------------------------------------------------------------- /ui/src/hooks/pagination.ts: -------------------------------------------------------------------------------- 1 | import { SetURLSearchParams } from 'react-router-dom'; 2 | import { URLSearchParams } from 'url'; 3 | import { useState } from 'react'; 4 | 5 | export const usePagination = ( 6 | initialLimit: number = 50, 7 | searchParams: URLSearchParams, 8 | setSearchParams: SetURLSearchParams 9 | ) => { 10 | const [limit, setLimit] = useState(initialLimit); 11 | 12 | const getOffset = () => { 13 | const offset = searchParams.get('offset'); 14 | return offset ? parseInt(offset) : 0; 15 | }; 16 | 17 | const setOffset = (value: number) => { 18 | setSearchParams((prev) => { 19 | prev.set('offset', `${value}`); 20 | return prev; 21 | }); 22 | }; 23 | 24 | const nextPage = () => { 25 | setOffset(getOffset() + limit); 26 | }; 27 | 28 | const previousPage = () => { 29 | setOffset(getOffset() - limit); 30 | }; 31 | 32 | const firstPage = () => { 33 | setOffset(0); 34 | }; 35 | 36 | const lastPage = (total: number) => { 37 | setOffset(Math.floor(total / limit) * limit); 38 | }; 39 | 40 | return { 41 | limit, 42 | nextPage, 43 | previousPage, 44 | getOffset, 45 | lastPage, 46 | firstPage, 47 | setLimit, 48 | }; 49 | }; 50 | -------------------------------------------------------------------------------- /ui/src/hooks/session.ts: -------------------------------------------------------------------------------- 1 | import { ApiError, deleteSession, deleteUserSession } from '@/clients/api/api'; 2 | 3 | import { useNotification } from './notification'; 4 | import { useRevalidator } from 'react-router-dom'; 5 | import { useTranslation } from 'react-i18next'; 6 | 7 | export const useManageSessions = () => { 8 | const { notifyError, notifySuccess, dismissNotification } = 9 | useNotification('manageSessions'); 10 | const revalidator = useRevalidator(); 11 | 12 | const { t } = useTranslation(); 13 | 14 | const _revokeSession = async (id: number) => { 15 | dismissNotification(); 16 | 17 | return await deleteSession(id) 18 | .then(() => { 19 | notifySuccess(t('session_revoked')); 20 | revalidator.revalidate(); 21 | }) 22 | .catch((error) => { 23 | if ( 24 | error instanceof ApiError && 25 | error.code === 400 && 26 | error.message === 'current session cannot be revoked' 27 | ) { 28 | notifyError(t('current_session_cannot_be_revoked')); 29 | } else { 30 | notifyError(t('unexpected')); 31 | } 32 | }); 33 | }; 34 | 35 | const _revokeUserSession = async (userId: number, sessionId: number) => { 36 | dismissNotification(); 37 | 38 | return await deleteUserSession(userId, sessionId) 39 | .then(() => { 40 | notifySuccess(t('session_revoked')); 41 | revalidator.revalidate(); 42 | }) 43 | .catch((error) => { 44 | if ( 45 | error instanceof ApiError && 46 | error.code === 400 && 47 | error.message === 'current session cannot be revoked' 48 | ) { 49 | notifyError(t('current_session_cannot_be_revoked')); 50 | } else { 51 | notifyError(t('unexpected')); 52 | } 53 | }); 54 | }; 55 | 56 | return { 57 | revokeSession: _revokeSession, 58 | revokeUserSession: _revokeUserSession, 59 | }; 60 | }; 61 | -------------------------------------------------------------------------------- /ui/src/hooks/token.ts: -------------------------------------------------------------------------------- 1 | import { createToken, deleteToken } from '@/clients/api/api'; 2 | 3 | import { useNotification } from './notification'; 4 | import { useRevalidator } from 'react-router-dom'; 5 | import { useState } from 'react'; 6 | import { useTranslation } from 'react-i18next'; 7 | 8 | export const useManageTokens = () => { 9 | const { notifyError, notifySuccess, dismissNotification } = 10 | useNotification('manageTokens'); 11 | const { t } = useTranslation(); 12 | const revalidator = useRevalidator(); 13 | 14 | const _revokeToken = async (id: number) => { 15 | dismissNotification(); 16 | 17 | return await deleteToken(id) 18 | .then(() => { 19 | notifySuccess(t('session_revoked')); 20 | revalidator.revalidate(); 21 | }) 22 | .catch(() => { 23 | notifyError(t('unexpected')); 24 | }); 25 | }; 26 | 27 | return { 28 | revokeToken: _revokeToken, 29 | }; 30 | }; 31 | 32 | export const useCreateToken = () => { 33 | const { notifyError, dismissNotification } = useNotification('createToken'); 34 | 35 | const [token, setToken] = useState(''); 36 | 37 | const { t } = useTranslation(); 38 | 39 | const _createToken = async (name: string) => { 40 | dismissNotification(); 41 | 42 | return await createToken(name) 43 | .then((res) => { 44 | setToken(res.token); 45 | }) 46 | .catch(() => { 47 | notifyError(t('unexpected')); 48 | }); 49 | }; 50 | 51 | return { 52 | createToken: _createToken, 53 | setToken, 54 | token, 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /ui/src/i18n/i18n.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import i18n, { t } from 'i18next'; 3 | 4 | test('can initialize i18n', async () => { 5 | // set prod to true, to reduce logs. 6 | import.meta.env.PROD = true; 7 | 8 | await import('./i18n'); 9 | 10 | expect(i18n.isInitialized).toBeTruthy(); 11 | expect(t('username')).toEqual('Username'); 12 | }); 13 | -------------------------------------------------------------------------------- /ui/src/i18n/i18n.ts: -------------------------------------------------------------------------------- 1 | import LanguageDetector from 'i18next-browser-languagedetector'; 2 | import de from './locales/de/translations.json'; 3 | import en from './locales/en/translations.json'; 4 | import es from './locales/es/translations.json'; 5 | import i18n from 'i18next'; 6 | import { initReactI18next } from 'react-i18next'; 7 | import moment from 'moment/min/moment-with-locales'; 8 | 9 | const fallbackLng = 'en'; 10 | 11 | i18n.on('languageChanged', (lng) => { 12 | const timeLocale = localStorage.getItem('time_locale'); 13 | 14 | if (timeLocale) { 15 | moment.locale(timeLocale); 16 | } else { 17 | moment.locale(lng); 18 | } 19 | }); 20 | 21 | i18n 22 | .use(LanguageDetector) 23 | .use(initReactI18next) 24 | .init({ 25 | fallbackLng, 26 | debug: !import.meta.env.PROD, 27 | supportedLngs: ['de', 'en', 'es'], 28 | resources: { 29 | de: { 30 | translation: de, 31 | }, 32 | en: { 33 | translation: en, 34 | }, 35 | es: { 36 | translation: es, 37 | }, 38 | }, 39 | 40 | interpolation: { 41 | escapeValue: false, 42 | }, 43 | react: { 44 | useSuspense: false, 45 | }, 46 | }); 47 | 48 | i18n.services.formatter?.add('moment', (value, _lng, options) => { 49 | return moment(new Date(value * 1000)).format(options.format); 50 | }); 51 | 52 | i18n.services.formatter?.add('event_duration', (value) => { 53 | return `${Math.floor((value.endsAt - value.startsAt) / 60)}`; 54 | }); 55 | 56 | export default i18n; 57 | -------------------------------------------------------------------------------- /ui/src/index.scss: -------------------------------------------------------------------------------- 1 | body { 2 | --color-danger-border: rgba(248, 81, 73, 0.4); 3 | --color-danger-bg: rgb(252, 84, 76); 4 | 5 | --color-success-border: rgba(0, 82, 4, 0.4); 6 | --color-success-bg: rgba(0, 82, 4, 1); 7 | 8 | --color-text-primary: #000; 9 | --color-text-subtle: rgba(0, 0, 0, 0.3); 10 | 11 | --color-card-bg: #ffffff; 12 | --color-border-subtle: #d8dee2; 13 | 14 | --color-bg: #f9f9f9; 15 | --color-accent-contrast: #2e2e2e; 16 | 17 | --color-nav-accent: #3c9ea1; 18 | 19 | &[data-theme='dark'] { 20 | --color-text-primary: #f3f3f3; 21 | --color-text-subtle: rgba(243, 243, 243, 0.3); 22 | 23 | --color-card-bg: rgb(24, 27, 31); 24 | --color-border-subtle: #23262d; 25 | 26 | --color-bg: rgb(17, 18, 23); 27 | --color-accent-contrast: #c1c1c1; 28 | 29 | --color-nav-accent: #6ad7e5; 30 | } 31 | 32 | --color-accent: #58a6ff; 33 | --color-accent-light: #58a6ff33; 34 | 35 | --toastify-color-light: var(--color-card-bg); 36 | --toastify-color-dark: var(--color-card-bg); 37 | 38 | margin: 0; 39 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 40 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 41 | sans-serif; 42 | -webkit-font-smoothing: antialiased; 43 | -moz-osx-font-smoothing: grayscale; 44 | 45 | background-color: var(--color-bg); 46 | color: var(--color-text-primary); 47 | } 48 | 49 | code { 50 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 51 | monospace; 52 | } 53 | -------------------------------------------------------------------------------- /ui/src/main.tsx: -------------------------------------------------------------------------------- 1 | import '@/i18n/i18n'; 2 | import '@/index.scss'; 3 | 4 | import App from './App'; 5 | import ReactDOM from 'react-dom/client'; 6 | 7 | const root = ReactDOM.createRoot( 8 | document.getElementById('root') as HTMLElement 9 | ); 10 | 11 | root.render(); 12 | -------------------------------------------------------------------------------- /ui/src/modals/dvr/profile/DVRProfileSelectModal.module.scss: -------------------------------------------------------------------------------- 1 | .content { 2 | display: flex; 3 | flex-direction: column; 4 | padding: 2.5rem; 5 | } 6 | 7 | .headline { 8 | font-size: 1.5rem; 9 | text-align: center; 10 | max-width: 30rem; 11 | } 12 | 13 | .form { 14 | display: flex; 15 | flex-direction: column; 16 | gap: 1rem; 17 | } 18 | -------------------------------------------------------------------------------- /ui/src/modals/dvr/profile/DVRProfileSelectModal.tsx: -------------------------------------------------------------------------------- 1 | import Modal, { ModalProps } from '@/components/common/modal/Modal'; 2 | 3 | import Button from '@/components/common/button/Button'; 4 | import { DVRConfig } from '@/clients/api/api.types'; 5 | import Dropdown from '@/components/common/dropdown/Dropdown'; 6 | import styles from './DVRProfileSelectModal.module.scss'; 7 | import { useState } from 'react'; 8 | import { useTranslation } from 'react-i18next'; 9 | 10 | type Props = ModalProps & { 11 | profiles: Array; 12 | handleCreateRecording: (profileId: string) => void; 13 | }; 14 | 15 | const DVRProfileSelectModal = ({ 16 | profiles, 17 | handleCreateRecording, 18 | ...rest 19 | }: Props) => { 20 | const { t } = useTranslation(); 21 | 22 | const [profile, setProfile] = useState(profiles[0].id); 23 | 24 | return ( 25 | 26 |
27 |

{t('create_recording')}

28 | 29 |
30 | ({ 32 | title: profile.name || t('default_profile'), 33 | value: profile.id, 34 | }))} 35 | value={profile} 36 | onChange={setProfile} 37 | label={t('profile')} 38 | fullWidth 39 | /> 40 | 41 |
49 |
50 |
51 | ); 52 | }; 53 | 54 | export default DVRProfileSelectModal; 55 | -------------------------------------------------------------------------------- /ui/src/modals/recording/create/RecordingCreateModal.module.scss: -------------------------------------------------------------------------------- 1 | .content { 2 | display: flex; 3 | flex-direction: column; 4 | padding: 2.5rem; 5 | } 6 | 7 | .headline { 8 | font-size: 1.5rem; 9 | text-align: center; 10 | max-width: 30rem; 11 | } 12 | -------------------------------------------------------------------------------- /ui/src/modals/token/create/CreateTokenModal.module.scss: -------------------------------------------------------------------------------- 1 | .content { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | padding: 2.5rem; 6 | } 7 | 8 | .headline { 9 | font-size: 1.5rem; 10 | text-align: center; 11 | max-width: 30rem; 12 | } 13 | -------------------------------------------------------------------------------- /ui/src/modals/twoFactorAuth/disable/TwoFactorAuthDisableModal.module.scss: -------------------------------------------------------------------------------- 1 | .content { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | padding: 2.5rem; 6 | } 7 | 8 | .headline { 9 | font-size: 1.5rem; 10 | text-align: center; 11 | max-width: 30rem; 12 | } 13 | -------------------------------------------------------------------------------- /ui/src/modals/twoFactorAuth/setup/TwoFactorAuthSetupModal.module.scss: -------------------------------------------------------------------------------- 1 | .content { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | padding: 2.5rem; 6 | } 7 | 8 | .headline { 9 | font-size: 1.5rem; 10 | text-align: center; 11 | max-width: 30rem; 12 | } 13 | 14 | .qrCode { 15 | display: flex; 16 | justify-content: center; 17 | align-items: center; 18 | flex-direction: column; 19 | gap: 1rem; 20 | 21 | span { 22 | font-size: 0.8rem; 23 | font-weight: 300; 24 | text-align: center; 25 | } 26 | } 27 | 28 | .url { 29 | word-break: break-all; 30 | } 31 | -------------------------------------------------------------------------------- /ui/src/modals/user/create/UserCreateModal.module.scss: -------------------------------------------------------------------------------- 1 | .content { 2 | display: flex; 3 | flex-direction: column; 4 | padding: 2.5rem; 5 | } 6 | 7 | .headline { 8 | font-size: 1.5rem; 9 | text-align: center; 10 | max-width: 30rem; 11 | } 12 | 13 | .isAdmin { 14 | display: flex; 15 | align-items: center; 16 | gap: 1rem; 17 | 18 | label { 19 | font-weight: bold; 20 | font-size: 0.9rem; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ui/src/providers/AuthProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ApiError, getCurrentUser } from '@/clients/api/api'; 2 | import { PropsWithChildren, ReactElement, useEffect, useState } from 'react'; 3 | 4 | import { AuthContext } from '@/contexts/AuthContext'; 5 | import { UserResponse } from '@/clients/api/api.types'; 6 | import { useNotification } from '@/hooks/notification'; 7 | import { useTranslation } from 'react-i18next'; 8 | 9 | export default function AuthProvider({ 10 | children, 11 | }: PropsWithChildren): ReactElement { 12 | const { t } = useTranslation(); 13 | 14 | const [user, setUser] = useState(null); 15 | const [isLoading, setIsLoading] = useState(true); 16 | 17 | const { notifyError } = useNotification('authError'); 18 | 19 | useEffect(() => { 20 | getCurrentUser() 21 | .then((user) => { 22 | setUser(user); 23 | }) 24 | .catch((error) => { 25 | if (error instanceof ApiError && error.code === 401) { 26 | setUser(null); 27 | } else { 28 | notifyError(t('unexpected')); 29 | } 30 | }) 31 | .finally(() => setIsLoading(false)); 32 | }, [t, notifyError]); 33 | 34 | if (isLoading) { 35 | return <>; 36 | } 37 | 38 | return ( 39 | 45 | {children} 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /ui/src/providers/LoadingProvider.tsx: -------------------------------------------------------------------------------- 1 | import LoadingBar, { LoadingBarRef } from 'react-top-loading-bar'; 2 | import { Outlet, useNavigation } from 'react-router-dom'; 3 | import { ReactElement, useEffect, useRef, useState } from 'react'; 4 | 5 | import { LoadingContext } from '@/contexts/LoadingContext'; 6 | 7 | export default function LoadingProvider(): ReactElement { 8 | const { state } = useNavigation(); 9 | const [isLoading, setIsLoading] = useState(false); 10 | 11 | const ref = useRef(null); 12 | 13 | useEffect( 14 | () => 15 | isLoading ? ref.current?.continuousStart() : ref.current?.complete(), 16 | [isLoading] 17 | ); 18 | 19 | useEffect( 20 | () => 21 | state === 'loading' 22 | ? ref.current?.continuousStart() 23 | : ref.current?.complete(), 24 | [state] 25 | ); 26 | 27 | return ( 28 | 34 | 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /ui/src/providers/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, ReactElement, useEffect, useState } from 'react'; 2 | import { Theme, ThemeContext } from '@/contexts/ThemeContext'; 3 | 4 | const LOCAL_STORAGE_KEY = 'tvhgo_theme'; 5 | 6 | const saved = localStorage.getItem(LOCAL_STORAGE_KEY); 7 | 8 | export function ThemeProvider({ 9 | children, 10 | }: PropsWithChildren): ReactElement { 11 | const [theme, setTheme] = useState(Theme.DARK); 12 | 13 | useEffect(() => { 14 | if (!Object.values(Theme).includes(saved as Theme)) { 15 | return; 16 | } 17 | 18 | setTheme(saved as Theme); 19 | }, []); 20 | 21 | useEffect(() => { 22 | document.body.setAttribute('data-theme', theme); 23 | }, [theme]); 24 | 25 | function setThemePersistent(theme: Theme) { 26 | setTheme(theme); 27 | localStorage.setItem(LOCAL_STORAGE_KEY, theme); 28 | } 29 | 30 | return ( 31 | 37 | {children} 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /ui/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/vitest'; 2 | -------------------------------------------------------------------------------- /ui/src/utils/classNames.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | 3 | import { c } from './classNames'; 4 | 5 | test('should return correct class name', () => { 6 | const className = c('first', undefined, 'second'); 7 | expect(className).toEqual('first second'); 8 | }); 9 | 10 | test('should return empty class name', () => { 11 | const className = c(); 12 | expect(className).toEqual(''); 13 | }); 14 | -------------------------------------------------------------------------------- /ui/src/utils/classNames.ts: -------------------------------------------------------------------------------- 1 | export function c(...classes: (string | undefined | null)[]) { 2 | return classes.filter((cn) => !!cn).join(' '); 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/views/channels/detail/ChannelView.module.scss: -------------------------------------------------------------------------------- 1 | .channel { 2 | padding: 0 3rem 3rem 3rem; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | .header { 8 | padding: 2rem 0; 9 | position: sticky; 10 | top: 3.5rem; 11 | background-color: var(--color-bg); 12 | z-index: 1; 13 | } 14 | 15 | .events { 16 | flex: 1; 17 | display: flex; 18 | flex-direction: column; 19 | gap: 1rem; 20 | margin-bottom: 2rem; 21 | } 22 | -------------------------------------------------------------------------------- /ui/src/views/channels/list/ChannelListView.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 2rem; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | .channelList { 8 | margin-bottom: 2rem; 9 | flex: 1; 10 | } 11 | -------------------------------------------------------------------------------- /ui/src/views/dashboard/DashboardView.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | display: flex; 3 | height: 100vh; 4 | width: 100%; 5 | } 6 | 7 | .navigation { 8 | display: none; 9 | position: fixed; 10 | left: 0; 11 | top: 0; 12 | width: 14rem; 13 | height: 100%; 14 | z-index: 100; 15 | margin-top: 3.5rem; 16 | 17 | &.expanded { 18 | display: block; 19 | } 20 | } 21 | 22 | .main { 23 | position: relative; 24 | flex: 1 1 0%; 25 | width: 0; 26 | min-width: 0; 27 | margin-top: 3.5rem; 28 | } 29 | 30 | @media screen and (min-width: 700px) { 31 | .navigation { 32 | display: block; 33 | } 34 | 35 | .main { 36 | margin-left: 14rem; 37 | } 38 | } 39 | 40 | .loading { 41 | opacity: 0.5; 42 | } 43 | -------------------------------------------------------------------------------- /ui/src/views/dvr/config/detail/DVRConfigDetailView.module.scss: -------------------------------------------------------------------------------- 1 | .view { 2 | padding: 3rem; 3 | display: flex; 4 | flex-direction: column; 5 | gap: 2rem; 6 | } 7 | 8 | .row { 9 | display: flex; 10 | flex-wrap: wrap; 11 | gap: 2rem; 12 | } 13 | 14 | .section { 15 | flex: 1; 16 | display: flex; 17 | flex-direction: column; 18 | gap: 1rem; 19 | } 20 | -------------------------------------------------------------------------------- /ui/src/views/dvr/config/list/DVRConfigListView.module.scss: -------------------------------------------------------------------------------- 1 | .view { 2 | padding: 3rem; 3 | display: flex; 4 | flex-direction: column; 5 | gap: 1rem; 6 | } 7 | 8 | .link { 9 | color: var(--color-accent); 10 | text-decoration: none; 11 | font-weight: bold; 12 | 13 | &:hover { 14 | text-decoration: underline; 15 | } 16 | } 17 | 18 | .actions { 19 | width: 0; 20 | } 21 | 22 | .path { 23 | display: none; 24 | } 25 | 26 | .time { 27 | display: none; 28 | } 29 | 30 | .default { 31 | display: none; 32 | } 33 | 34 | @media screen and (min-width: 1536px) { 35 | .path { 36 | display: table-cell; 37 | } 38 | } 39 | 40 | @media screen and (min-width: 1280px) { 41 | .time { 42 | display: table-cell; 43 | } 44 | } 45 | 46 | @media screen and (min-width: 640px) { 47 | .default { 48 | display: table-cell; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /ui/src/views/epg/event/EventView.module.scss: -------------------------------------------------------------------------------- 1 | .Event { 2 | padding: 3rem; 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/views/epg/guide/GuideView.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 0 2rem 2em 2rem; 3 | } 4 | 5 | .segment { 6 | display: grid; 7 | grid-auto-flow: column; 8 | grid-auto-columns: minmax(0, 1fr); 9 | gap: 2rem; 10 | } 11 | 12 | .header { 13 | padding-top: 2rem; 14 | background-color: var(--color-bg); 15 | z-index: 1; 16 | position: sticky; 17 | top: 3.5rem; 18 | } 19 | 20 | .channels { 21 | position: relative; 22 | margin-top: 1rem; 23 | } 24 | 25 | .bar { 26 | display: flex; 27 | flex-wrap: wrap; 28 | justify-content: space-between; 29 | align-items: center; 30 | } 31 | 32 | .dropdown { 33 | padding: 0.5rem; 34 | border: none; 35 | border-radius: 3px; 36 | color: var(--color-text-primary); 37 | background-color: var(--color-card-bg); 38 | option { 39 | color: black; 40 | background-color: white; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ui/src/views/login/LoginView.module.scss: -------------------------------------------------------------------------------- 1 | div.Login { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | height: 100vh; 6 | } 7 | -------------------------------------------------------------------------------- /ui/src/views/recordings/RecordingDetailView/RecordingDetailView.module.scss: -------------------------------------------------------------------------------- 1 | .RecordingDetailView { 2 | padding: 3rem; 3 | } 4 | 5 | .timeForm { 6 | margin-top: 2rem; 7 | } 8 | 9 | .actions { 10 | margin-top: 2rem; 11 | display: flex; 12 | justify-content: flex-start; 13 | gap: 1rem; 14 | } 15 | -------------------------------------------------------------------------------- /ui/src/views/recordings/RecordingsView/RecordingsView.module.scss: -------------------------------------------------------------------------------- 1 | .Recordings { 2 | padding: 0 2rem 2rem 2rem; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | .header { 8 | display: flex; 9 | justify-content: space-between; 10 | padding: 2rem 0; 11 | position: sticky; 12 | top: 3.5rem; 13 | background-color: var(--color-bg); 14 | z-index: 1; 15 | } 16 | 17 | .headerLeft { 18 | display: flex; 19 | flex-wrap: wrap; 20 | align-items: center; 21 | gap: 1rem; 22 | } 23 | 24 | .actions { 25 | display: flex; 26 | align-items: center; 27 | gap: 1rem; 28 | } 29 | 30 | .selectAll { 31 | margin-right: 0.5rem; 32 | } 33 | 34 | .deleteButton { 35 | flex: 0; 36 | visibility: hidden; 37 | 38 | &.deleteButtonVisible { 39 | visibility: visible; 40 | } 41 | } 42 | 43 | .recordings { 44 | flex: 1; 45 | display: flex; 46 | flex-direction: column; 47 | gap: 1rem; 48 | margin-bottom: 2rem; 49 | } 50 | 51 | .sort { 52 | display: flex; 53 | align-items: center; 54 | gap: 0.5rem; 55 | } 56 | 57 | .sortDirIcon { 58 | transform: scale(1.5) rotate(90deg); 59 | transition: transform 0.1s; 60 | 61 | &.desc { 62 | transform: scale(1.5) rotate(270deg); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /ui/src/views/search/SearchView.module.scss: -------------------------------------------------------------------------------- 1 | .view { 2 | padding: 3rem; 3 | display: flex; 4 | flex-direction: column; 5 | gap: 1rem; 6 | } 7 | -------------------------------------------------------------------------------- /ui/src/views/settings/SettingsView.module.scss: -------------------------------------------------------------------------------- 1 | .Settings { 2 | display: flex; 3 | flex-direction: column; 4 | padding: 2rem; 5 | } 6 | 7 | .content { 8 | gap: 3rem; 9 | display: flex; 10 | flex-direction: column; 11 | } 12 | 13 | .row { 14 | display: flex; 15 | flex-direction: column; 16 | flex-wrap: wrap; 17 | } 18 | 19 | .section { 20 | max-width: 30rem; 21 | flex: 1; 22 | display: flex; 23 | flex-direction: column; 24 | gap: 1rem; 25 | } 26 | -------------------------------------------------------------------------------- /ui/src/views/settings/SettingsView.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom'; 2 | import styles from './SettingsView.module.scss'; 3 | 4 | export function Component() { 5 | return ( 6 |
7 |
8 | 9 |
10 |
11 | ); 12 | } 13 | 14 | Component.displayName = 'SettingsView'; 15 | -------------------------------------------------------------------------------- /ui/src/views/settings/states.ts: -------------------------------------------------------------------------------- 1 | export enum SecuritySettingsRefreshStates { 2 | TWOFA = 'refresh_2fa', 3 | TOKEN = 'refresh_token', 4 | } 5 | -------------------------------------------------------------------------------- /ui/src/views/settings/users/detail/UserDetailView.module.scss: -------------------------------------------------------------------------------- 1 | .view { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 2rem; 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/views/settings/users/list/UserListView.module.scss: -------------------------------------------------------------------------------- 1 | .users { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 1rem; 5 | } 6 | 7 | .header { 8 | display: flex; 9 | justify-content: space-between; 10 | align-items: center; 11 | } 12 | 13 | .link { 14 | color: var(--color-accent); 15 | text-decoration: none; 16 | font-weight: bold; 17 | 18 | &:hover { 19 | text-decoration: underline; 20 | } 21 | } 22 | 23 | .delete { 24 | width: 0; 25 | } 26 | 27 | .created { 28 | display: none; 29 | } 30 | 31 | .email { 32 | display: none; 33 | } 34 | 35 | .twofa { 36 | display: none; 37 | } 38 | 39 | .admin { 40 | display: none; 41 | } 42 | 43 | @media screen and (min-width: 1536px) { 44 | .created { 45 | display: table-cell; 46 | } 47 | } 48 | 49 | @media screen and (min-width: 1280px) { 50 | .twofa { 51 | display: table-cell; 52 | } 53 | 54 | .admin { 55 | display: table-cell; 56 | } 57 | } 58 | 59 | @media screen and (min-width: 640px) { 60 | .email { 61 | display: table-cell; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /ui/src/views/settings/users/list/states.ts: -------------------------------------------------------------------------------- 1 | export enum UserListRefreshStates { 2 | CREATED = 'created', 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | declare const __COMMIT_HASH__: string; 5 | declare const __VERSION__: string; 6 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "baseUrl": ".", 19 | "paths": { 20 | "@/*": ["src/*"] 21 | }, 22 | "types": ["node", "@testing-library/jest-dom"] 23 | }, 24 | "include": ["src"], 25 | "references": [{ "path": "./tsconfig.node.json" }] 26 | } 27 | -------------------------------------------------------------------------------- /ui/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /ui/ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "embed" 5 | "io/fs" 6 | "net/http" 7 | 8 | "github.com/go-chi/chi/v5" 9 | ) 10 | 11 | //go:embed dist 12 | var dist embed.FS 13 | 14 | func NewRouter() (http.Handler, error) { 15 | r := chi.NewRouter() 16 | 17 | uiFS, err := fs.Sub(dist, "dist") 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | srv := http.FileServer(http.FS(uiFS)) 23 | r.Get("/", srv.ServeHTTP) 24 | r.Get("/manifest.webmanifest", srv.ServeHTTP) 25 | r.Get("/favicon.ico", srv.ServeHTTP) 26 | r.Get("/assets/*", srv.ServeHTTP) 27 | r.Get("/img/*", srv.ServeHTTP) 28 | r.Get("/*", func(w http.ResponseWriter, r *http.Request) { 29 | r.URL.Path = "/" 30 | srv.ServeHTTP(w, r) 31 | }) 32 | 33 | return r, nil 34 | } 35 | -------------------------------------------------------------------------------- /ui/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path, { resolve } from 'path'; 2 | 3 | import { defineConfig } from 'vite'; 4 | import { env } from 'process'; 5 | import { openSync } from 'fs'; 6 | /// 7 | import react from '@vitejs/plugin-react'; 8 | import svgr from 'vite-plugin-svgr'; 9 | 10 | const commitHash = env.GIT_COMMIT || 'local'; 11 | const version = env.VERSION || 'local'; 12 | 13 | // This plugin creates a keep file to include the 14 | // dist directory to the version control but exclude the content. 15 | const keep = { 16 | closeBundle() { 17 | openSync(resolve(__dirname, 'dist/keep'), 'w'); 18 | }, 19 | name: 'Create static keep file for git', 20 | }; 21 | 22 | // https://vitejs.dev/config/ 23 | export default defineConfig({ 24 | define: { 25 | __COMMIT_HASH__: JSON.stringify(commitHash), 26 | __VERSION__: JSON.stringify(version), 27 | }, 28 | plugins: [react(), svgr(), keep], 29 | resolve: { 30 | alias: { 31 | '@': path.resolve(__dirname, './src'), 32 | }, 33 | }, 34 | server: { 35 | proxy: { 36 | '/api': 'http://localhost:8080', 37 | }, 38 | }, 39 | test: { 40 | coverage: { 41 | provider: 'istanbul', 42 | }, 43 | environment: 'jsdom', 44 | setupFiles: './src/setupTests.ts', 45 | }, 46 | }); 47 | --------------------------------------------------------------------------------