├── .cargo └── config.toml ├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── check-license.py │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── AUTHORS ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── design ├── README.md ├── glossary.md ├── schema.md ├── signal.md ├── time-frames.png └── time.md ├── guide ├── README.md ├── build.md ├── developing-ui.md ├── install-version.png ├── install.md ├── schema.md ├── secure.md └── troubleshooting.md ├── ref ├── README.md ├── api.md └── config.md ├── screenshots ├── list.png └── live.jpg ├── server ├── Cargo.lock ├── Cargo.toml ├── Cross.toml ├── base │ ├── Cargo.toml │ ├── clock.rs │ ├── error.rs │ ├── lib.rs │ ├── shutdown.rs │ ├── strutil.rs │ ├── time.rs │ └── tracing_setup.rs ├── build.rs ├── db │ ├── Cargo.toml │ ├── auth.rs │ ├── build.rs │ ├── check.rs │ ├── coding.rs │ ├── compare.rs │ ├── days.rs │ ├── db.rs │ ├── dir │ │ ├── mod.rs │ │ └── reader.rs │ ├── fs.rs │ ├── json.rs │ ├── lib.rs │ ├── proto │ │ └── schema.proto │ ├── raw.rs │ ├── recording.rs │ ├── schema.sql │ ├── signal.rs │ ├── testdata │ │ ├── avc1 │ │ └── video_sample_index.bin │ ├── testutil.rs │ ├── upgrade │ │ ├── mod.rs │ │ ├── v0.sql │ │ ├── v0_to_v1.rs │ │ ├── v1.sql │ │ ├── v1_to_v2.rs │ │ ├── v2_to_v3.rs │ │ ├── v3.sql │ │ ├── v3_to_v4.rs │ │ ├── v4_to_v5.rs │ │ ├── v5.sql │ │ ├── v5_to_v6.rs │ │ ├── v6.sql │ │ └── v6_to_v7.rs │ └── writer.rs └── src │ ├── body.rs │ ├── bundled_ui.rs │ ├── cmds │ ├── check.rs │ ├── config │ │ ├── cameras.rs │ │ ├── dirs.rs │ │ ├── mod.rs │ │ ├── tab_complete.rs │ │ └── users.rs │ ├── init.rs │ ├── login.rs │ ├── mod.rs │ ├── run │ │ ├── config.rs │ │ └── mod.rs │ ├── sql.rs │ ├── ts.rs │ └── upgrade │ │ └── mod.rs │ ├── json.rs │ ├── main.rs │ ├── mp4.rs │ ├── slices.rs │ ├── stream.rs │ ├── streamer.rs │ ├── testdata │ └── clip.mp4 │ └── web │ ├── accept.rs │ ├── live.rs │ ├── mod.rs │ ├── path.rs │ ├── session.rs │ ├── signals.rs │ ├── static_file.rs │ ├── users.rs │ ├── view.rs │ └── websocket.rs └── ui ├── .gitignore ├── .prettierignore ├── README.md ├── index.html ├── package.json ├── pnpm-lock.yaml ├── public ├── favicons │ ├── android-chrome-192x192-22fa756c4c8a94dde.png │ ├── android-chrome-512x512-0403b1c77057918bb.png │ ├── apple-touch-icon-94a09b5d2ddb5af47.png │ ├── favicon-16x16-b16b3f2883aacf9f1.png │ ├── favicon-32x32-ab95901a9e0d040e2.png │ ├── favicon-e6c276d91e88aab6f.ico │ └── safari-pinned-tab-9792c2c82f04639f8.svg ├── robots.txt └── site.webmanifest ├── src ├── App.test.tsx ├── App.tsx ├── AppMenu.tsx ├── ChangePassword.tsx ├── ErrorBoundary.test.tsx ├── ErrorBoundary.tsx ├── List │ ├── DisplaySelector.tsx │ ├── StreamMultiSelector.tsx │ ├── TimerangeSelector.tsx │ ├── VideoList.test.tsx │ ├── VideoList.tsx │ └── index.tsx ├── Live │ ├── LiveCamera.tsx │ ├── Multiview.tsx │ ├── index.tsx │ └── parser.ts ├── Login.test.tsx ├── Login.tsx ├── NewPassword.tsx ├── Users │ ├── AddEditDialog.tsx │ ├── DeleteDialog.tsx │ └── index.tsx ├── api.ts ├── aspect.ts ├── components │ ├── Header.tsx │ └── ThemeMode.tsx ├── index.css ├── index.tsx ├── logo.svg ├── react-app-env.d.ts ├── setupTests.ts ├── snackbars.test.tsx ├── snackbars.tsx ├── testutil.tsx └── types.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── vitest.config.ts /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | rustflags = "-C force-frame-pointers=yes" 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /server/target 2 | /ui/build 3 | /ui/node_modules 4 | /ui/yarn-error.log 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report or support request 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of the problem. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Server (please complete the following information):** 27 | - `moonfire-nvr --version` 28 | - Attach a [log file](https://github.com/scottlamb/moonfire-nvr/blob/master/guide/troubleshooting.md#viewing-moonfire-nvrs-logs). Run with the `RUST_BACKTRACE=1` environment variable set if possible. 29 | 30 | **Camera (please complete the following information):** 31 | - Camera manufacturer and model: [e.g. Reolink RLC-410] 32 | - Firmware version: [e.g. `V2.0.0.1215_16091800`] 33 | 34 | **Desktop (please complete the following information):** 35 | - OS: [e.g. iOS] 36 | - Browser [e.g. chrome, safari] 37 | - Version [e.g. 22] 38 | 39 | **Smartphone (please complete the following information):** 40 | - Device: [e.g. iPhone6] 41 | - OS: [e.g. iOS8.1] 42 | - Browser [e.g. stock browser, safari] 43 | - Version [e.g. 22] 44 | 45 | **Additional context** 46 | Add any other context about the problem here. 47 | -------------------------------------------------------------------------------- /.github/workflows/check-license.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # This file is part of Moonfire NVR, a security camera network video recorder. 3 | # Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 4 | # SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 5 | """Checks that expected header lines are present. 6 | 7 | Call in either of two modes: 8 | 9 | has-license.py FILE [...] 10 | check if all files with certain extensions have expected lines. 11 | This is useful in a CI action. 12 | 13 | has-license.py 14 | check if stdin has expected lines. 15 | This is useful in a pre-commit hook, as in 16 | git-format-staged --no-write --formatter '.../has-license.py' '*.rs' 17 | """ 18 | import re 19 | import sys 20 | 21 | # Filenames matching this regexp are expected to have the header lines. 22 | FILENAME_MATCHER = re.compile(r'.*\.([jt]sx?|html|css|py|rs|sh|sql)$') 23 | 24 | MAX_LINE_COUNT = 10 25 | 26 | EXPECTED_LINES = [ 27 | re.compile(r'This file is part of Moonfire NVR, a security camera network video recorder\.'), 28 | re.compile(r'Copyright \(C\) 20\d{2} The Moonfire NVR Authors; see AUTHORS and LICENSE\.txt\.'), 29 | re.compile(r'SPDX-License-Identifier: GPL-v3\.0-or-later WITH GPL-3\.0-linking-exception\.?'), 30 | ] 31 | 32 | def has_license(f): 33 | """Returns if all of EXPECTED_LINES are present within the first 34 | MAX_LINE_COUNT lines of f.""" 35 | needed = set(EXPECTED_LINES) 36 | i = 0 37 | for line in f: 38 | if i == 10: 39 | break 40 | i += 1 41 | for e in needed: 42 | if e.search(line): 43 | needed.remove(e) 44 | break 45 | if not needed: 46 | return True 47 | return False 48 | 49 | 50 | def file_has_license(filename): 51 | with open(filename, 'r') as f: 52 | return has_license(f) 53 | 54 | 55 | def main(args): 56 | if not args: 57 | sys.exit(0 if has_license(sys.stdin) else 1) 58 | 59 | missing = [f for f in args 60 | if FILENAME_MATCHER.match(f) and not file_has_license(f)] 61 | if missing: 62 | print('The following files are missing expected copyright/license headers:', file=sys.stderr) 63 | print('\n'.join(missing), file=sys.stderr) 64 | sys.exit(1) 65 | 66 | 67 | if __name__ == '__main__': 68 | main(sys.argv[1:]) 69 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | 4 | defaults: 5 | run: 6 | shell: bash 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | MOONFIRE_COLOR: always 11 | RUST_BACKTRACE: 1 12 | 13 | jobs: 14 | rust: 15 | name: Rust ${{ matrix.rust }} 16 | strategy: 17 | matrix: 18 | rust: ["stable", "1.82", "nightly"] 19 | include: 20 | - rust: nightly 21 | extra_args: "--features nightly --benches" 22 | - rust: stable 23 | extra_components: rustfmt 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | with: 29 | # `git describe` output gets baked into the binary for `moonfire-nvr --version`. 30 | # Fetch all revs so it can see tag history. 31 | fetch-depth: 0 32 | filter: "tree:0" 33 | - name: Cache 34 | uses: actions/cache@v4 35 | with: 36 | path: | 37 | ~/.cargo/registry 38 | ~/.cargo/git 39 | server/target 40 | key: cargo-${{ matrix.rust }}-${{ hashFiles('**/Cargo.lock') }} 41 | restore-keys: | 42 | cargo-${{ matrix.rust }}- 43 | cargo- 44 | - uses: actions/setup-node@v3 45 | with: 46 | node-version: 18 47 | - name: Install Rust 48 | uses: actions-rs/toolchain@v1 49 | with: 50 | profile: minimal 51 | toolchain: ${{ matrix.rust }} 52 | override: true 53 | components: ${{ matrix.extra_components }} 54 | - name: Test 55 | run: | 56 | cd server 57 | cargo test --features=rusqlite/bundled ${{ matrix.extra_args }} --all 58 | continue-on-error: ${{ matrix.rust == 'nightly' }} 59 | - name: Check formatting 60 | if: matrix.rust == 'stable' 61 | run: cd server && cargo fmt --all -- --check 62 | js: 63 | name: Node ${{ matrix.node }} 64 | strategy: 65 | matrix: 66 | node: ["18", "20", "21"] 67 | runs-on: ubuntu-latest 68 | steps: 69 | - uses: actions/checkout@v4 70 | - uses: actions/setup-node@v3 71 | with: 72 | node-version: ${{ matrix.node }} 73 | # Install pnpm then use pnpm instead npm 74 | - run: npm i -g pnpm 75 | - run: cd ui && pnpm i --frozen-lockfile 76 | - run: cd ui && pnpm run build 77 | - run: cd ui && pnpm run test 78 | - run: cd ui && pnpm run lint 79 | - run: cd ui && pnpm run check-format 80 | license: 81 | name: Check copyright/license headers 82 | runs-on: ubuntu-latest 83 | steps: 84 | - name: Checkout 85 | uses: actions/checkout@v4 86 | - run: find . -type f -print0 | xargs -0 .github/workflows/check-license.py 87 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | defaults: 4 | run: 5 | shell: bash 6 | 7 | env: 8 | DOCKER_TAG: "ghcr.io/${{ github.repository }}:${{ github.ref_name }}" 9 | 10 | on: 11 | push: 12 | tags: 13 | - v[0-9]+.* 14 | 15 | jobs: 16 | base: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | - uses: taiki-e/install-action@v2 22 | with: 23 | tool: parse-changelog 24 | - name: Generate changelog 25 | run: | 26 | VERSION_MINUS_V=${GITHUB_REF_NAME/#v/} 27 | parse-changelog CHANGELOG.md $VERSION_MINUS_V > CHANGELOG-$GITHUB_REF_NAME.md 28 | - uses: actions/setup-node@v3 29 | with: 30 | node-version: 18 31 | - run: npm i -g pnpm 32 | - run: cd ui && pnpm i --frozen-lockfile 33 | - run: cd ui && pnpm run build 34 | - run: cd ui && pnpm run test 35 | # Upload the UI and changelog as *job* artifacts (not *release* artifacts), used below. 36 | - uses: actions/upload-artifact@v4 37 | with: 38 | name: moonfire-nvr-ui-${{ github.ref_name }} 39 | path: ui/dist 40 | if-no-files-found: error 41 | - uses: actions/upload-artifact@v4 42 | with: 43 | name: CHANGELOG-${{ github.ref_name }} 44 | path: CHANGELOG-${{ github.ref_name }}.md 45 | if-no-files-found: error 46 | 47 | cross: 48 | needs: base # for bundled ui 49 | permissions: 50 | contents: read 51 | packages: write 52 | strategy: 53 | matrix: 54 | include: 55 | # Note: keep these arches in sync with `Upload Docker Manifest` list. 56 | - arch: x86_64 # as in `uname -m` on Linux. 57 | rust_target: x86_64-unknown-linux-musl # as in 58 | docker_platform: linux/amd64 # as in 59 | - arch: aarch64 60 | rust_target: aarch64-unknown-linux-musl 61 | docker_platform: linux/arm64 62 | - arch: armv7l 63 | rust_target: armv7-unknown-linux-musleabihf 64 | docker_platform: linux/arm/v7 65 | fail-fast: false 66 | runs-on: ubuntu-latest 67 | steps: 68 | - name: Checkout 69 | uses: actions/checkout@v4 70 | - name: Download UI 71 | uses: actions/download-artifact@v4 72 | with: 73 | name: moonfire-nvr-ui-${{ github.ref_name }} 74 | path: ui/dist 75 | 76 | # actions-rust-cross doesn't actually use cross for x86_64. 77 | # Install the needed musl-tools in the host. 78 | - name: Install musl-tools 79 | run: sudo apt-get --option=APT::Acquire::Retries=3 update && sudo apt-get --option=APT::Acquire::Retries=3 install musl-tools 80 | if: matrix.rust_target == 'x86_64-unknown-linux-musl' 81 | - name: Build 82 | uses: houseabsolute/actions-rust-cross@v0 83 | env: 84 | UI_BUILD_DIR: ../ui/dist 85 | 86 | # cross doesn't install `git` within its Docker container, so plumb 87 | # the version through rather than try `git describe` from `build.rs`. 88 | VERSION: ${{ github.ref_name }} 89 | with: 90 | working-directory: server 91 | target: ${{ matrix.rust_target }} 92 | command: build 93 | args: --release --features bundled,mimalloc 94 | - name: Upload Docker Artifact 95 | run: | 96 | tag="${DOCKER_TAG}-${{ matrix.arch }}" 97 | mkdir output 98 | ln ./server/target/${{ matrix.rust_target }}/release/moonfire-nvr output/ 99 | echo ${{secrets.GITHUB_TOKEN}} | docker login --username ${{github.actor}} --password-stdin ghcr.io 100 | docker build --platform ${{ matrix.docker_platform }} --push --tag "${tag}" --file - output < 2 | Dolf Starreveld 3 | Sky1e 4 | michioxd 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Moonfire NVR 2 | 3 | Hi, I'm Scott, Moonfire NVR's author. I'd love your help in making it great. 4 | There are lots of ways you can contribute. 5 | 6 | * [Saying hi](#saying-hi) 7 | * [Asking for support](#asking-for-support) 8 | * [Offering support](#offering-support) 9 | * [Filing bug and enhancement issues](#filing-bug-and-enhancement-issues) 10 | * [Contributing documentation](#contributing-documentation) 11 | * [Contributing code and UI changes](#contributing-code-and-ui-changes) 12 | 13 | ## Saying hi 14 | 15 | Please say hi on the [mailing 16 | list](https://groups.google.com/g/moonfire-nvr-users) or in [github 17 | discussions](https://github.com/scottlamb/moonfire-nvr/discussions) after 18 | trying out Moonfire NVR. Often open source authors only hear from users when 19 | something goes wrong. I love to hear when it works well, too. It's motivating 20 | to know Moonfire NVR is helping people. And knowing how people want to use 21 | Moonfire NVR will guide development. 22 | 23 | Great example: [this Show & Tell from JasonKleban](https://github.com/scottlamb/moonfire-nvr/discussions/118). 24 | 25 | ## Asking for support 26 | 27 | When you're stuck, look at the [troubleshooting 28 | guide](guide/troubleshooting.md). If it doesn't answer your question, please 29 | ask for help! Support requests are welcome on the 30 | [issue tracker](https://github.com/scottlamb/moonfire-nvr/issues). 31 | Often they help create good bug reports and enhancement requests. 32 | 33 | ## Offering support 34 | 35 | Answering someone else's question is a great way to help them and to test your 36 | own understanding. You can also turn their support request into a bug report 37 | or enhancement request. 38 | 39 | ## Filing bug and enhancement issues 40 | 41 | First skim the [github issue 42 | tracker](https://github.com/scottlamb/moonfire-nvr/issues) to see if someone 43 | has already reported your problem. If so, no need to file a new issue. Instead: 44 | 45 | * +1 the first comment so we know how many people are affected. 46 | * subscribe so you know what's happening. 47 | * add a comment if you can help understand the problem. 48 | 49 | If there's no existing issue, file a new one: 50 | 51 | * bugs: follow the [template](https://github.com/scottlamb/moonfire-nvr/issues/new?assignees=&labels=bug&template=bug_report.md&title=). 52 | * enhancement requests: there's no template. Use your best judgement. 53 | 54 | Please be understanding if your issue isn't marked as top priority. I have 55 | many things I want to improve and only so much time. If you think something 56 | is more important than I do, you might be able to convince me, but the most 57 | effective approach is to send a PR. 58 | 59 | ## Contributing documentation 60 | 61 | Moonfire NVR has checked-in documentation (in [guide](guide/) and 62 | [design](design/) directories) to describe a particular version of Moonfire 63 | NVR. Please send a github PR for changes. I will review them for accuracy 64 | and clarity. 65 | 66 | There's also a [wiki](https://github.com/scottlamb/moonfire-nvr/wiki). This 67 | is for anything else: notes on compatibility with a particular camera, how to 68 | configure your Linux system and network for recording, hardware 69 | recommendations, etc. This area is less formal. No review is necessary; just 70 | make a change. 71 | 72 | You could be the first to create a [YouTube tour](https://github.com/scottlamb/moonfire-nvr/issues/82) or start other forms of documentation! 73 | 74 | ## Contributing code and UI changes 75 | 76 | I love seeing code and user interface contributions. 77 | 78 | * Small changes: just send a PR. In most cases just propose a change against 79 | `master`. 80 | * Large changes: let's discuss the design first. We can talk on the issue 81 | tracker, via email, or over video chat. 82 | 83 | "Small" or "large" is about how you'd feel if your change isn't merged. 84 | Imagine you go through all the effort of making a change and sending a PR, 85 | then I suggest an alternate approach or point out your PR conflicts with some 86 | other work on a development branch. You have to start over. 87 | 88 | * if you'd be **frustrated** or **angry**, your change is **large**. Let's 89 | agree on a design first so you know you'll be successful before putting 90 | in this much work. When you're ready, open a PR. We'll polish and merge 91 | it quickly. 92 | * if you'd be **happy** to revise, your change is **small**. Send a PR 93 | right away. I'd love to see your prototype and help you turn it into 94 | finished software. 95 | 96 | The [Building Moonfire NVR](guide/build.md) and [Working on UI 97 | development](guide/developing-ui.md) guides should help you get started. 98 | The [design documents](design/) will help you fit your work into the whole. 99 | The wiki has a page to help you find copies of [standards and 100 | specifications](https://github.com/scottlamb/moonfire-nvr/wiki/Standards-and-specifications) 101 | that Moonfire NVR interacts with. 102 | 103 | Please tell me when you get stuck! Every software developer knows in theory 104 | there's parts of their code that aren't as clear and well-documented as they 105 | should be. It's a whole other thing to know an unclear spot is actually 106 | stopping someone from understanding and contributing. When that happens, I'm 107 | happy to explain, expand design docs, write more comments, and revise code 108 | for clarity. 109 | 110 | I promise to review PRs promptly, even if it's for an issue I wouldn't 111 | prioritize on my own. Together we can do more. 112 | 113 | If you're looking for something to do: 114 | 115 | * Please skim issues with the [`1.0` or `1.0?` 116 | milestone](https://github.com/scottlamb/moonfire-nvr/issues?q=is%3Aopen+is%3Aissue+milestone%3A1.0+milestone%3A1.0%3F+). Let's ship a minimum viable product! 117 | * Please help with UI and video analytics. These aren't my field of expertise. 118 | Maybe you can teach me. 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI](https://github.com/scottlamb/moonfire-nvr/workflows/CI/badge.svg)](https://github.com/scottlamb/moonfire-nvr/actions?query=workflow%3ACI) 2 | 3 | * [Introduction](#introduction) 4 | * [Documentation](#documentation) 5 | 6 | # Introduction 7 | 8 | Moonfire NVR is an open-source security camera network video recorder, started 9 | by Scott Lamb <>. It saves H.264-over-RTSP streams from 10 | IP cameras to disk into a hybrid format: video frames in a directory on 11 | spinning disk, other data in a SQLite3 database on flash. It can construct 12 | `.mp4` files for arbitrary time ranges on-the-fly. It does not decode, 13 | analyze, or re-encode video frames, so it requires little CPU. It handles six 14 | 1080p/30fps streams on a [Raspberry Pi 15 | 2](https://www.raspberrypi.org/products/raspberry-pi-2-model-b/), using 16 | less than 10% of the machine's total CPU. 17 | 18 | **Help wanted to make it great! Please see the [contributing 19 | guide](CONTRIBUTING.md).** 20 | 21 | So far, the web interface is basic: a filterable list of video segments, 22 | with support for trimming them to arbitrary time ranges. No scrub bar yet. 23 | There's also an experimental live view UI. 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
list view screenshotlive view screenshot
33 | 34 | There's no support yet for motion detection, no https/TLS support (you'll 35 | need a proxy server, as described [here](guide/secure.md)), and only a 36 | console-based (rather than web-based) configuration UI. 37 | 38 | Moonfire NVR is pre-1.0, with will be no compatibility guarantees: 39 | configuration and storage formats may change from version to version. There is 40 | an [upgrade procedure](guide/schema.md) but it is not for the faint of heart. 41 | 42 | I hope to add features such as video analytics. In time, we can build 43 | a full-featured hobbyist-oriented multi-camera NVR that requires nothing but 44 | a cheap machine with a big hard drive. There are many exciting techniques we 45 | could use to make this possible: 46 | 47 | * avoiding CPU-intensive H.264 encoding in favor of simply continuing to use 48 | the camera's already-encoded video streams. Cheap IP cameras these days 49 | provide pre-encoded H.264 streams in both "main" (full-sized) and "sub" 50 | (lower resolution, compression quality, and/or frame rate) varieties. The 51 | "sub" stream is more suitable for fast computer vision work as well as 52 | remote/mobile streaming. Disk space these days is quite cheap (with 4 TB 53 | drives costing about $100), so we can afford to keep many camera-months 54 | of both streams on disk. 55 | * off-loading on-NVR analytics to an inexpensive USB or M.2 neural network 56 | accelerator and hardware H.264 decoders. 57 | * taking advantage of on-camera analytics. They're often not as accurate, but 58 | they're the best way to stretch very inexpensive NVR machines. 59 | 60 | # Documentation 61 | 62 | * [Contributing](CONTRIBUTING.md) 63 | * [License](LICENSE.txt) — 64 | [GPL-3.0-or-later](https://spdx.org/licenses/GPL-3.0-or-later.html) 65 | with [GPL-3.0-linking-exception](https://spdx.org/licenses/GPL-3.0-linking-exception.html) 66 | for OpenSSL. 67 | * [Change log](CHANGELOG.md) / release notes. 68 | * [Guides](guide/), including: 69 | * [Installing](guide/install.md) 70 | * [Building from source](guide/build.md) 71 | * [Securing Moonfire NVR and exposing it to the Internet](guide/secure.md) 72 | * [UI Development](guide/developing-ui.md) 73 | * [Troubleshooting](guide/troubleshooting.md) 74 | * [References](ref/), including: 75 | * [Configuration file](ref/config.md) 76 | * [JSON API](ref/api.md) 77 | * [Design documents](design/) 78 | * [Wiki](https://github.com/scottlamb/moonfire-nvr/wiki) has hardware 79 | recommendations, notes on several camera models, etc. Please add more! 80 | -------------------------------------------------------------------------------- /design/README.md: -------------------------------------------------------------------------------- 1 | Design documents and [Architectural Decision Records](https://adr.github.io/) 2 | for Moonfire NVR. Meant for developers. 3 | -------------------------------------------------------------------------------- /design/glossary.md: -------------------------------------------------------------------------------- 1 | # Moonfire NVR Glossary 2 | 3 | *GOP:* Group of Pictures, as 4 | [described](https://en.wikipedia.org/wiki/Group_of_pictures) on wikipedia. 5 | Each GOP starts with an "IDR" or "key" frame which can be decoded by itself. 6 | Commonly all other frames in the GOP are encoded in terms of the frames before, 7 | so decoding frame 5 requires decoding frame 1, 2, 3, and 4. Many security 8 | cameras produce a new IDR frame (thus start a new GOP) at a fixed interval of 9 | 1 or 2 seconds. Some cameras that use "smart encoding" or "H.264+" may produce 10 | GOPs that vary in length, up to several seconds. 11 | 12 | *media duration:* the total duration of the actual samples in a recording. These 13 | durations are based on the camera's clock. Camera clocks can be quite 14 | inaccurate, so this may not match the *wall duration*. See [time.md](time.md) 15 | for details. 16 | 17 | *open id:* a sequence number representing a time the database was opened in 18 | write mode. One reason for using open ids is to disambiguate unflushed 19 | recordings. Recordings' ids are assigned immediately, without any kind of 20 | database transaction or reservation. Thus if a recording is never flushed 21 | successfully, a following *open* may assign the same id to a new recording. 22 | The open id disambiguates this and should be used whenever referring to a 23 | recording that may be unflushed. 24 | 25 | *ppm:* Part Per Million. Crystal Clock accuracy is defined in terms of ppm or 26 | parts per million and it gives a convenient way of comparing accuracies 27 | of different crystal specifications. "A typical crystal has an error of 28 | 100ppm (ish) this translates as 100/1e6 or (1e-4)...So the total error on a day 29 | is 86400 x 1e-4= 8.64 seconds per day. In a month you would loose 30 | 30x8.64 = 259 seconds or 4.32 minutes per month." 31 | Source: https://www.best-microcontroller-projects.com/ppm.html 32 | 33 | *recording:* the video from a (typically 1-minute) portion of an RTSP session. 34 | RTSP sessions are divided into recordings as a detail of the 35 | storage schema. See [schema.md](schema.md) for details. This concept is exposed 36 | to the frontend code through the API; see [../ref/api.md](../ref/api.md). It's 37 | not exposed in the user interface; videos are reconstructed from segments 38 | automatically. 39 | 40 | *run:* all the recordings from a single RTSP session. These are all from the 41 | same *stream* and could be reassembled into a single video with no gaps. If the 42 | camera is lost and re-established, one run ends and another starts. 43 | 44 | *sample:* data associated with a single timestamp within a recording, e.g. a video 45 | frame or a set of 46 | 47 | *sample file:* a file on disk that holds all the samples from a single recording. 48 | 49 | *sample file directory:* a directory in the local filesystem that holds all 50 | sample files for one or more streams. Typically there is one directory per disk. 51 | 52 | *segment:* part or all of a recording. An API request might ask for a video of 53 | recordings 1–4 starting 80 seconds in. If each recording is exactly 60 seconds, 54 | this would correspond to three segments: recording 2 from 20 seconds in to 55 | the end, all of recording 3, and all of recording 4. See 56 | [../ref/api.md](../ref/api.md). 57 | 58 | *session:* a set of authenticated Moonfire NVR requests defined by the use of a 59 | given credential (`s` cookie). Each user may have many credentials and thus 60 | many sessions. Note that in Moonfire NVR's the term "session" by itself has 61 | nothing to do with RTSP sessions; those more closely match a *run*. 62 | 63 | *signal:* a timeseries with an enum value. Signals might represent a camera's 64 | motion detection or day/night status. They could also represent an external 65 | input such as a burglar alarm system's zone status. See 66 | [../ref/api.md](../ref/api.md). Note signals are still under development and 67 | not yet exposed in Moonfire NVR's UI. See 68 | [#28](https://github.com/scottlamb/moonfire-nvr/issues/28) for more 69 | information. 70 | 71 | *stream:* the "main" or "sub" stream from a given camera. Moonfire NVR expects 72 | cameras support configuring and simultaneously viewing two streams encoded from 73 | the same underlying video and audio source. The difference between the two is 74 | that the "main" stream's video is typically higher quality in terms of frame 75 | rate, resolution, and bitrate. Likewise it may have higher quality audio. 76 | A stream corresponds to an ONVIF "media profile". Each stream has a distinct 77 | RTSP URL that yields a difference RTSP "presentation". 78 | 79 | *track:* one of the video, audio, or subtitles associated with a single 80 | *stream*. This is consistent with the definition in ISO/IEC 14496-12 section 81 | 3.1.19. Note that RTSP RFC 2326 uses the word "stream" in the same way 82 | Moonfire NVR uses the word "track". 83 | 84 | *wall duration:* the total duration of a recording for the purpose of matching 85 | with the NVR's wall clock time. This may not match the same recording's media 86 | duration. See [time.md](time.md) for details. 87 | -------------------------------------------------------------------------------- /design/signal.md: -------------------------------------------------------------------------------- 1 | # Moonfire NVR Signals 2 | 3 | Status: **draft**. 4 | 5 | "Signals" are what Moonfire NVR uses to describe non-video timeseries data 6 | such as "was motion detected?" or "what mode was my burglar alarm in?" They are 7 | intended to be displayed in the UI with the video scrub bar to aid in finding 8 | a relevant portion of video. 9 | 10 | ## Objective 11 | 12 | Goals: 13 | 14 | * represent simple results of on-camera and on-NVR motion detection, e.g.: 15 | `true`, `false`, or `unknown`. 16 | * represent external signals such as burglar alarm state, e.g.: 17 | `off`, `stay`, `away`, `alarm`, or `unknown`. 18 | 19 | Non-goals: 20 | 21 | * provide meaningful data when the NVR has inaccurate system time. 22 | * support internal state necessary for on-NVR motion detection. (This will 23 | be considered separately.) 24 | * support fine-grained outputs such as "what are the bounding boxes of all 25 | detected faces?", "what cells have motion?", audio volume, or audio 26 | spectograms. 27 | 28 | ## Overview 29 | 30 | hmm, two ideas: 31 | 32 | * just use timestamps everywhere. allow adding/updating historical data. 33 | * only allow updating the current open. initially, just support setting 34 | current time. then support extending from a previous request. no ability 35 | to fill in while NVR is down. 36 | -------------------------------------------------------------------------------- /design/time-frames.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottlamb/moonfire-nvr/104ffdc2dc55bd5115c35328725e9f73efbb9b43/design/time-frames.png -------------------------------------------------------------------------------- /guide/README.md: -------------------------------------------------------------------------------- 1 | Guides to using and contributing to Moonfire NVR. 2 | -------------------------------------------------------------------------------- /guide/build.md: -------------------------------------------------------------------------------- 1 | # Building Moonfire NVR 2 | 3 | This document has notes for software developers on building Moonfire NVR from 4 | source code for development. If you just want to install precompiled 5 | binaries, see the [installation instructions](install.md) instead. 6 | 7 | This document doesn't spell out as many details as the installation 8 | instructions. Please ask on Moonfire NVR's [issue 9 | tracker](https://github.com/scottlamb/moonfire-nvr/issues) or 10 | [mailing list](https://groups.google.com/d/forum/moonfire-nvr-users) when 11 | stuck. Please also send pull requests to improve this doc. 12 | 13 | * [Downloading](#downloading) 14 | * [Building](#building) 15 | * [Running interactively straight from the working copy](#running-interactively-straight-from-the-working-copy) 16 | * [Release procedure](#release-procedure) 17 | 18 | ## Downloading 19 | 20 | See the [github page](https://github.com/scottlamb/moonfire-nvr) (in case 21 | you're not reading this text there already). You can download the 22 | bleeding-edge version from the commandline via git: 23 | 24 | ```console 25 | $ git clone https://github.com/scottlamb/moonfire-nvr.git 26 | $ cd moonfire-nvr 27 | ``` 28 | 29 | ## Building 30 | 31 | Moonfire NVR should run natively on any Unix-like system. It's been tested on 32 | Linux, macOS, and FreeBSD. (In theory [Windows Subsystem for 33 | Linux](https://docs.microsoft.com/en-us/windows/wsl/about) should also work. 34 | Please speak up if you try it.) 35 | 36 | To build the server, you will need [SQLite3](https://www.sqlite.org/). You 37 | can skip this if compiling with `--features=rusqlite/bundled` and don't 38 | mind the `moonfire-nvr sql` command not working. 39 | 40 | To build the UI, you'll need a [nodejs](https://nodejs.org/en/download/) release 41 | in "Maintenance", "LTS", or "Current" status on the 42 | [Release Schedule](https://github.com/nodejs/release#release-schedule): 43 | currently v18, v20, or v21. 44 | 45 | On recent Ubuntu or Raspbian Linux, the following command will install 46 | most non-Rust dependencies: 47 | 48 | ```console 49 | $ sudo apt-get install \ 50 | build-essential \ 51 | libsqlite3-dev \ 52 | pkgconf \ 53 | sqlite3 \ 54 | tzdata 55 | ``` 56 | 57 | Ubuntu 20.04 LTS (still popular, supported by Ubuntu until April 2025) bundles 58 | node 10, which has reached end-of-life (see 59 | [node.js: releases](https://nodejs.org/en/about/releases/)). 60 | So rather than install the `nodejs` and `npm` packages from the built-in 61 | repository, see [Installing Node.js via package 62 | manager](https://nodejs.org/en/download/package-manager/#debian-and-ubuntu-based-linux-distributions). 63 | 64 | On macOS with [Homebrew](https://brew.sh/) and Xcode installed, try the 65 | following command: 66 | 67 | ```console 68 | $ brew install node 69 | ``` 70 | 71 | Next, you need Rust 1.82+ and Cargo. The easiest way to install them is by 72 | following the instructions at [rustup.rs](https://www.rustup.rs/). Avoid 73 | your Linux distribution's Rust packages, which tend to be too old. 74 | (At least on Debian-based systems; Arch and Gentoo might be okay.) 75 | 76 | Once prerequisites are installed, you can build the server and find it in 77 | `target/release/moonfire-nvr`: 78 | 79 | ```console 80 | $ cd server 81 | $ cargo test 82 | $ cargo build --release 83 | $ sudo install -m 755 target/release/moonfire-nvr /usr/local/bin 84 | $ cd .. 85 | ``` 86 | 87 | You can build the UI via `pnpm` and find it in the `ui/build` directory: 88 | 89 | ```console 90 | $ cd ui 91 | $ pnpm install 92 | $ pnpm run build 93 | $ sudo mkdir /usr/local/lib/moonfire-nvr 94 | $ cd .. 95 | $ sudo rsync --recursive --delete --chmod=D755,F644 ui/dist/ /usr/local/lib/moonfire-nvr/ui 96 | ``` 97 | 98 | For more information about using `pnpm`, check out the [Developing UI Guide](./developing-ui.md#requirements). 99 | 100 | If you wish to bundle the UI into the binary, you can build the UI first and then pass 101 | `--features=bundled-ui` when building the server. See also the 102 | [release workflow](../.github/workflows/release.yml) which statically links SQLite and 103 | (musl-based) libc for a zero-dependencies binary. 104 | 105 | ### Running interactively straight from the working copy 106 | 107 | The author finds it convenient for local development to set up symlinks so that 108 | the binaries in the working copy will run via just `nvr`: 109 | 110 | ```console 111 | $ sudo mkdir /usr/local/lib/moonfire-nvr 112 | $ sudo ln -s `pwd`/ui/dist /usr/local/lib/moonfire-nvr/ui 113 | $ sudo mkdir /var/lib/moonfire-nvr 114 | $ sudo chown $USER: /var/lib/moonfire-nvr 115 | $ ln -s `pwd`/server/target/release/moonfire-nvr $HOME/bin/moonfire-nvr 116 | $ ln -s moonfire-nvr $HOME/bin/nvr 117 | $ nvr init 118 | $ nvr config 119 | $ nvr run 120 | ``` 121 | 122 | (Alternatively, you could symlink to `target/debug/moonfire-nvr` and compile 123 | with `cargo build` rather than `cargo build --release`, for a faster build 124 | cycle and slower performance.) 125 | 126 | ## Release procedure 127 | 128 | Releases are currently a bit manual. From a completely clean git work tree, 129 | 130 | 1. manually verify the current commit is pushed to github's master branch and 131 | has a green checkmark indicating CI passed. 132 | 2. update versions: 133 | * update `server/Cargo.toml` version by hand; run `cargo test --workspace` 134 | to update `Cargo.lock`. 135 | * ensure `README.md` and `CHANGELOG.md` refer to the new version. 136 | 3. run commands: 137 | ```bash 138 | VERSION=x.y.z 139 | git commit -am "prepare version ${VERSION}" 140 | git tag -a "v${VERSION}" -m "version ${VERSION}" 141 | git push origin "v${VERSION}" 142 | ``` 143 | 144 | The rest should happen automatically—the tag push will fire off a GitHub 145 | Actions workflow which creates a release, cross-compiles statically compiled 146 | binaries for three different platforms, and uploads them to the release. 147 | -------------------------------------------------------------------------------- /guide/developing-ui.md: -------------------------------------------------------------------------------- 1 | # Developing the UI 2 | 3 | * [Getting started](#getting-started) 4 | * [Overriding defaults](#overriding-defaults) 5 | * [A note on `https`](#a-note-on-https) 6 | 7 | The UI is presented from a single HTML page (index.html) and any number 8 | of Javascript files, css files, images, etc. These are "packed" together 9 | using [vite](https://vitejs.dev/). 10 | 11 | For ongoing development it is possible to have the UI running in a web 12 | browser using "hot loading". This means that as you make changes to source 13 | files, they will be detected, the webpack will be recompiled and generated 14 | and then the browser will be informed to reload things. In combination with 15 | the debugger built into modern browsers this makes for a reasonable process. 16 | 17 | For a production build, the same process is followed, except with different 18 | settings. In particular, no hot loading development server will be started 19 | and more effort is expended on packing and minimizing the components of 20 | the application as represented in the various "bundles". Read more about 21 | this in the webpack documentation. 22 | 23 | ## Requirements 24 | 25 | * Node.js v18+ 26 | * `pnpm` installed 27 | 28 | This guide below will use [`pnpm`](https://pnpm.io/) as package manager instead 29 | `npm`. So we highly recommended you to use `pnpm` in this project. 30 | 31 | ## Getting started 32 | 33 | Checkout the branch you want to work on and type 34 | 35 | ```bash 36 | cd ui 37 | pnpm run dev 38 | ``` 39 | 40 | This will pack and prepare a development setup. By default the development 41 | server that serves up the web page(s) will listen on 42 | [http://localhost:5173/](http://localhost:5173/) so you can direct your browser 43 | there. It assumes the Moonfire NVR server is running at 44 | [http://localhost:8080/](http://localhost:8080/) and will proxy API requests 45 | there. 46 | 47 | Make any changes to the source code as you desire (look at existing code 48 | for examples and typical style), and the browser will hot-load your changes. 49 | Often times you will make mistakes. Anything from a coding error (for which 50 | you can use the browser's debugger), or compilation breaking Javascript errors. 51 | The latter will often be reported as errors during the webpack assembly 52 | process, but some will show up in the browser console, or both. 53 | 54 | ## Overriding defaults 55 | 56 | Currently there's only one supported environment variable override defined in 57 | `ui/vite.config.ts`: 58 | 59 | | variable | description | default | 60 | | :------------- | :------------------------------------------ | :----------------------- | 61 | | `PROXY_TARGET` | base URL of the backing Moonfire NVR server | `http://localhost:8080/` | 62 | 63 | Thus one could connect to a remote Moonfire NVR by specifying its URL as 64 | follows: 65 | 66 | ```bash 67 | PROXY_TARGET=https://nvr.example.com/ npm run dev 68 | ``` 69 | 70 | This allows you to test a new UI against your stable, production Moonfire NVR 71 | installation with real data. 72 | 73 | You can also set environment variables in `.env` files, as described in 74 | [vitejs.dev: Env Variables and Modes](https://vitejs.dev/guide/env-and-mode). 75 | 76 | ## A note on `https` 77 | 78 | Commonly production setups require credentials and run over `https`, as 79 | described in [secure.md](secure.md). Furthermore, Moonfire NVR will set the 80 | `secure` attribute on cookies it receives over `https`, so that the browser 81 | will only send them over a `https` connection. 82 | 83 | This is great for security and somewhat inconvenient for proxying. 84 | Fundamentally, there are three ways to make it work: 85 | 86 | 1. Configure the proxy server with valid credentials to supply on every 87 | request, without requiring the browser to authenticate. 88 | 2. Configure the proxy server to strip out the `secure` attribute from 89 | cookie response headers, so the browser will send them to the proxy 90 | server. 91 | 3. Configure the proxy server with a TLS certificate. 92 | a. using a self-signed certificate manually added to the browser's 93 | store. 94 | b. using a certificate from a "real" Certificate Authority (such as 95 | letsencrypt). 96 | 97 | Currently the configuration only implements method 2. It's easy to configure 98 | but has a couple caveats: 99 | 100 | * if you alternate between proxying to a test Moonfire NVR 101 | installation and a real one, your browser won't know the difference. It 102 | will supply whichever credentials were sent to it last. 103 | * if you connect via a host other than localhost, your browser will have a 104 | production cookie that it's willing to send to a remote host over a 105 | non-`https` connection. If you ever load this website using an 106 | untrustworthy DNS server, your credentials can be compromised. 107 | 108 | We might add support for method 3 in the future. It's less convenient to 109 | configure but can avoid these problems. 110 | -------------------------------------------------------------------------------- /guide/install-version.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottlamb/moonfire-nvr/104ffdc2dc55bd5115c35328725e9f73efbb9b43/guide/install-version.png -------------------------------------------------------------------------------- /ref/README.md: -------------------------------------------------------------------------------- 1 | Reference documentation for Moonfire NVR. 2 | -------------------------------------------------------------------------------- /screenshots/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottlamb/moonfire-nvr/104ffdc2dc55bd5115c35328725e9f73efbb9b43/screenshots/list.png -------------------------------------------------------------------------------- /screenshots/live.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottlamb/moonfire-nvr/104ffdc2dc55bd5115c35328725e9f73efbb9b43/screenshots/live.jpg -------------------------------------------------------------------------------- /server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "moonfire-nvr" 3 | version = "0.0.0" 4 | authors = ["Scott Lamb "] 5 | edition = "2021" 6 | resolver = "2" 7 | license-file = "../LICENSE.txt" 8 | rust-version = "1.82" 9 | publish = false 10 | 11 | [features] 12 | # The nightly feature is used within moonfire-nvr itself to gate the 13 | # benchmarks. Also pass it along to crates that can benefit from it. 14 | nightly = ["db/nightly"] 15 | 16 | # The bundled feature aims to make a single executable file that is deployable, 17 | # including statically linked libraries and embedded UI files. 18 | bundled = ["rusqlite/bundled", "bundled-ui"] 19 | 20 | bundled-ui = [] 21 | mimalloc = ["base/mimalloc"] 22 | 23 | [workspace] 24 | members = ["base", "db"] 25 | 26 | [workspace.dependencies] 27 | base64 = "0.22.0" 28 | h264-reader = "0.8.0" 29 | itertools = "0.14.0" 30 | jiff = "0.1.6" 31 | nix = "0.27.0" 32 | pretty-hex = "0.4.0" 33 | ring = "0.17.0" 34 | rusqlite = "0.34.0" 35 | tracing = { version = "0.1" } 36 | tracing-core = "0.1.30" 37 | tracing-futures = { version = "0.2.5", features = ["futures-03", "std-future"] } 38 | tracing-log = "0.2" 39 | tracing-subscriber = { version = "0.3.16" } 40 | uuid = { version = "1.1.2", features = ["serde", "std", "v7", "fast-rng"] } 41 | 42 | [dependencies] 43 | base = { package = "moonfire-base", path = "base" } 44 | base64 = { workspace = true } 45 | blake3 = "1.0.0" 46 | bpaf = { version = "0.9.15", features = [ 47 | "autocomplete", 48 | "bright-color", 49 | "derive", 50 | ] } 51 | bytes = "1" 52 | byteorder = "1.0" 53 | cursive = { version = "0.21.1", default-features = false, features = [ 54 | "termion-backend", 55 | ] } 56 | data-encoding = "2.7.0" 57 | db = { package = "moonfire-db", path = "db" } 58 | futures = "0.3" 59 | h264-reader = { workspace = true } 60 | http = "1.1.0" 61 | http-serve = { version = "0.4.0-rc.1", features = ["dir"] } 62 | hyper = { version = "1.4.1", features = ["http1", "server"] } 63 | itertools = { workspace = true } 64 | jiff = { workspace = true, features = ["tz-system"] } 65 | libc = "0.2" 66 | log = { version = "0.4" } 67 | memchr = "2.0.2" 68 | nix = { workspace = true, features = ["time", "user"] } 69 | nom = "7.0.0" 70 | password-hash = "0.5.0" 71 | pretty-hex = { workspace = true } 72 | protobuf = "3.0" 73 | reffers = "0.7.0" 74 | retina = "0.4.13" 75 | ring = { workspace = true } 76 | rusqlite = { workspace = true } 77 | serde = { version = "1.0", features = ["derive"] } 78 | serde_json = "1.0" 79 | smallvec = { version = "1.7", features = ["union"] } 80 | tokio = { version = "1.24", features = [ 81 | "macros", 82 | "rt-multi-thread", 83 | "signal", 84 | "sync", 85 | "time", 86 | ] } 87 | tokio-tungstenite = "0.26.1" 88 | toml = "0.8" 89 | tracing = { workspace = true, features = ["log"] } 90 | tracing-subscriber = { version = "0.3.16", features = ["env-filter", "json"] } 91 | tracing-core = "0.1.30" 92 | tracing-futures = { version = "0.2.5", features = ["futures-03", "std-future"] } 93 | tracing-log = { workspace = true } 94 | url = "2.1.1" 95 | uuid = { workspace = true } 96 | flate2 = "1.0.26" 97 | hyper-util = { version = "0.1.7", features = ["server-graceful", "tokio"] } 98 | http-body = "1.0.1" 99 | http-body-util = "0.1.2" 100 | pin-project = "1.1.10" 101 | subtle = "2.6.1" 102 | 103 | [target.'cfg(target_os = "linux")'.dependencies] 104 | libsystemd = "0.7.0" 105 | 106 | [build-dependencies] 107 | ahash = "0.8" 108 | blake3 = "1.0.0" 109 | walkdir = "2.3.3" 110 | 111 | [dev-dependencies] 112 | mp4 = { git = "https://github.com/scottlamb/mp4-rust", branch = "moonfire" } 113 | num-rational = { version = "0.4.0", default-features = false, features = [ 114 | "std", 115 | ] } 116 | reqwest = { version = "0.12.0", default-features = false, features = ["json"] } 117 | tempfile = "3.2.0" 118 | tracing-test = "0.2.4" 119 | 120 | [profile.dev.package.scrypt] 121 | # On an Intel i3-6100U @ 2.30 GHz, a single scrypt password hash takes 7.6 122 | # seconds at opt-level=0, or 0.096 seconds at opt-level=2. Always optimize this 123 | # crate to avoid seeming hung / being annoyingly slow when debugging. 124 | opt-level = 2 125 | 126 | [profile.release] 127 | debug = 1 128 | 129 | [profile.release-lto] 130 | inherits = "release" 131 | lto = true 132 | 133 | [profile.bench] 134 | debug = 1 135 | 136 | [patch.crates-io] 137 | 138 | # Override the `tracing` crate versions with a branch that updates the 139 | # `matchers` dependency to avoid duplicate `regex-automata` crate versions. 140 | # This branch is based on tracing's `0.1.x` branch with changes similar to 141 | # applied. 142 | tracing = { git = "https://github.com/scottlamb/tracing", rev = "861b443d7b2da400ca7b09111957f33c80135908" } 143 | tracing-core = { git = "https://github.com/scottlamb/tracing", rev = "861b443d7b2da400ca7b09111957f33c80135908" } 144 | tracing-log = { git = "https://github.com/scottlamb/tracing", rev = "861b443d7b2da400ca7b09111957f33c80135908" } 145 | tracing-subscriber = { git = "https://github.com/scottlamb/tracing", rev = "861b443d7b2da400ca7b09111957f33c80135908" } 146 | -------------------------------------------------------------------------------- /server/Cross.toml: -------------------------------------------------------------------------------- 1 | [build.env] 2 | volumes = [ 3 | # For the (optional) `bundled-ui` feature. 4 | "UI_BUILD_DIR", 5 | 6 | # For tests which use the `America/Los_Angeles` zone. 7 | "ZONEINFO=/usr/share/zoneinfo", 8 | ] 9 | 10 | passthrough = [ 11 | # Cross's default docker image doesn't install `git`, so `git_version!` doesn't work. 12 | # Allow passing through the version via this environment variable. 13 | "VERSION", 14 | ] 15 | -------------------------------------------------------------------------------- /server/base/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "moonfire-base" 3 | version = "0.0.0" 4 | authors = ["Scott Lamb "] 5 | readme = "../README.md" 6 | edition = "2021" 7 | license-file = "../../LICENSE.txt" 8 | publish = false 9 | rust-version = "1.82" 10 | 11 | [features] 12 | mimalloc = ["dep:libmimalloc-sys"] 13 | nightly = [] 14 | 15 | [lib] 16 | path = "lib.rs" 17 | 18 | [dependencies] 19 | ahash = "0.8" 20 | coded = { git = "https://github.com/scottlamb/coded", rev = "2c97994974a73243d5dd12134831814f42cdb0e8" } 21 | futures = "0.3" 22 | jiff = { workspace = true } 23 | libc = "0.2" 24 | libmimalloc-sys = { git = "https://github.com/scottlamb/mimalloc_rust", branch = "musl-fix", features = [ 25 | "override", 26 | "extended", 27 | ], optional = true } 28 | nix = { workspace = true, features = ["time"] } 29 | nom = "7.0.0" 30 | rusqlite = { workspace = true } 31 | serde = { version = "1.0", features = ["derive"] } 32 | serde_json = "1.0" 33 | slab = "0.4" 34 | tracing = { workspace = true } 35 | tracing-core = { workspace = true } 36 | tracing-log = { workspace = true } 37 | tracing-subscriber = { workspace = true, features = ["env-filter", "json"] } 38 | -------------------------------------------------------------------------------- /server/base/lib.rs: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. 4 | 5 | pub mod clock; 6 | pub mod error; 7 | pub mod shutdown; 8 | pub mod strutil; 9 | pub mod time; 10 | pub mod tracing_setup; 11 | 12 | pub use crate::error::{Error, ErrorBuilder, ErrorKind, ResultExt}; 13 | 14 | pub use ahash::RandomState; 15 | pub type FastHashMap = std::collections::HashMap; 16 | pub type FastHashSet = std::collections::HashSet; 17 | 18 | const NOT_POISONED: &str = 19 | "not poisoned; this is a consequence of an earlier panic while holding this mutex; see logs."; 20 | 21 | /// [`std::sync::Mutex`] wrapper which always panics on encountering poison. 22 | #[derive(Default)] 23 | pub struct Mutex(std::sync::Mutex); 24 | 25 | impl Mutex { 26 | #[inline] 27 | pub const fn new(value: T) -> Self { 28 | Mutex(std::sync::Mutex::new(value)) 29 | } 30 | 31 | #[track_caller] 32 | #[inline] 33 | pub fn lock(&self) -> std::sync::MutexGuard { 34 | self.0.lock().expect(NOT_POISONED) 35 | } 36 | 37 | #[track_caller] 38 | #[inline] 39 | pub fn into_inner(self) -> T { 40 | self.0.into_inner().expect(NOT_POISONED) 41 | } 42 | } 43 | 44 | /// [`std::sync::Condvar`] wrapper which always panics on encountering poison. 45 | #[derive(Default)] 46 | pub struct Condvar(std::sync::Condvar); 47 | 48 | impl Condvar { 49 | #[inline] 50 | pub const fn new() -> Self { 51 | Self(std::sync::Condvar::new()) 52 | } 53 | 54 | #[track_caller] 55 | #[inline] 56 | pub fn wait_timeout_while<'a, T, F>( 57 | &self, 58 | guard: std::sync::MutexGuard<'a, T>, 59 | dur: std::time::Duration, 60 | condition: F, 61 | ) -> (std::sync::MutexGuard<'a, T>, std::sync::WaitTimeoutResult) 62 | where 63 | F: FnMut(&mut T) -> bool, 64 | { 65 | self.0 66 | .wait_timeout_while(guard, dur, condition) 67 | .expect(NOT_POISONED) 68 | } 69 | } 70 | 71 | impl std::ops::Deref for Condvar { 72 | type Target = std::sync::Condvar; 73 | 74 | fn deref(&self) -> &Self::Target { 75 | &self.0 76 | } 77 | } 78 | 79 | pub fn ensure_malloc_used() { 80 | #[cfg(feature = "mimalloc")] 81 | { 82 | // This is a load-bearing debug line. 83 | // Building `libmimalloc-sys` with the `override` feature will override `malloc` and 84 | // `free` as used through the Rust global allocator, SQLite, and `libc`. But...`cargo` 85 | // doesn't seem to build `libmimalloc-sys` at all if it's not referenced from Rust code. 86 | tracing::debug!("mimalloc version {}", unsafe { 87 | libmimalloc_sys::mi_version() 88 | }) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /server/base/strutil.rs: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2016 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. 4 | 5 | use nom::branch::alt; 6 | use nom::bytes::complete::{tag, take_while1}; 7 | use nom::character::complete::space0; 8 | use nom::combinator::{map, map_res, opt}; 9 | use nom::sequence::{delimited, tuple}; 10 | use nom::IResult; 11 | use std::fmt::Write as _; 12 | 13 | static MULTIPLIERS: [(char, u64); 4] = [ 14 | // (suffix character, power of 2) 15 | ('T', 40), 16 | ('G', 30), 17 | ('M', 20), 18 | ('K', 10), 19 | ]; 20 | 21 | /// Encodes a non-negative size into human-readable form. 22 | pub fn encode_size(mut raw: i64) -> String { 23 | let mut encoded = String::new(); 24 | for &(c, n) in &MULTIPLIERS { 25 | if raw >= 1i64 << n { 26 | write!(&mut encoded, "{}{} ", raw >> n, c).unwrap(); 27 | raw &= (1i64 << n) - 1; 28 | } 29 | } 30 | if raw > 0 || encoded.is_empty() { 31 | write!(&mut encoded, "{raw}").unwrap(); 32 | } else { 33 | encoded.pop(); // remove trailing space. 34 | } 35 | encoded 36 | } 37 | 38 | fn decode_sizepart(input: &str) -> IResult<&str, i64> { 39 | map( 40 | tuple(( 41 | map_res(take_while1(|c: char| c.is_ascii_digit()), |input: &str| { 42 | input.parse::() 43 | }), 44 | opt(alt(( 45 | nom::combinator::value(1 << 40, tag("T")), 46 | nom::combinator::value(1 << 30, tag("G")), 47 | nom::combinator::value(1 << 20, tag("M")), 48 | nom::combinator::value(1 << 10, tag("K")), 49 | ))), 50 | )), 51 | |(n, opt_unit)| n * opt_unit.unwrap_or(1), 52 | )(input) 53 | } 54 | 55 | fn decode_size_internal(input: &str) -> IResult<&str, i64> { 56 | nom::multi::fold_many1( 57 | delimited(space0, decode_sizepart, space0), 58 | || 0, 59 | |sum, i| sum + i, 60 | )(input) 61 | } 62 | 63 | /// Decodes a human-readable size as output by encode_size. 64 | #[allow(clippy::result_unit_err)] 65 | pub fn decode_size(encoded: &str) -> Result { 66 | let (remaining, decoded) = decode_size_internal(encoded).map_err(|_e| ())?; 67 | if !remaining.is_empty() { 68 | return Err(()); 69 | } 70 | Ok(decoded) 71 | } 72 | 73 | /// Returns a hex-encoded version of the input. 74 | pub fn hex(raw: &[u8]) -> String { 75 | #[rustfmt::skip] 76 | const HEX_CHARS: [u8; 16] = [ 77 | b'0', b'1', b'2', b'3', b'4', b'5', b'6', b'7', 78 | b'8', b'9', b'a', b'b', b'c', b'd', b'e', b'f', 79 | ]; 80 | let mut hex = Vec::with_capacity(2 * raw.len()); 81 | for b in raw { 82 | hex.push(HEX_CHARS[((b & 0xf0) >> 4) as usize]); 83 | hex.push(HEX_CHARS[(b & 0x0f) as usize]); 84 | } 85 | unsafe { String::from_utf8_unchecked(hex) } 86 | } 87 | 88 | /// Returns [0, 16) or error. 89 | #[allow(clippy::result_unit_err)] 90 | fn dehex_byte(hex_byte: u8) -> Result { 91 | match hex_byte { 92 | b'0'..=b'9' => Ok(hex_byte - b'0'), 93 | b'a'..=b'f' => Ok(hex_byte - b'a' + 10), 94 | _ => Err(()), 95 | } 96 | } 97 | 98 | /// Returns a 20-byte raw form of the given hex string. 99 | /// (This is the size of a SHA1 hash, the only current use of this function.) 100 | #[allow(clippy::result_unit_err)] 101 | pub fn dehex(hexed: &[u8]) -> Result<[u8; 20], ()> { 102 | if hexed.len() != 40 { 103 | return Err(()); 104 | } 105 | let mut out = [0; 20]; 106 | for i in 0..20 { 107 | out[i] = (dehex_byte(hexed[i << 1])? << 4) + dehex_byte(hexed[(i << 1) + 1])?; 108 | } 109 | Ok(out) 110 | } 111 | 112 | #[cfg(test)] 113 | mod tests { 114 | use super::*; 115 | 116 | #[test] 117 | fn test_decode() { 118 | assert_eq!(super::decode_size("100M").unwrap(), 100i64 << 20); 119 | assert_eq!(super::decode_size("100M 42").unwrap(), (100i64 << 20) + 42); 120 | } 121 | 122 | #[test] 123 | fn round_trip() { 124 | let s = "de382684a471f178e4e3a163762711b0653bfd83"; 125 | let dehexed = dehex(s.as_bytes()).unwrap(); 126 | assert_eq!(&hex(&dehexed[..]), s); 127 | } 128 | 129 | #[test] 130 | fn dehex_errors() { 131 | dehex(b"").unwrap_err(); 132 | dehex(b"de382684a471f178e4e3a163762711b0653bfd8g").unwrap_err(); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /server/db/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "moonfire-db" 3 | version = "0.0.0" 4 | authors = ["Scott Lamb "] 5 | readme = "../README.md" 6 | edition = "2021" 7 | license-file = "../../LICENSE.txt" 8 | rust-version = "1.82" 9 | publish = false 10 | 11 | [features] 12 | nightly = [] 13 | 14 | [lib] 15 | path = "lib.rs" 16 | 17 | [dependencies] 18 | base = { package = "moonfire-base", path = "../base" } 19 | base64 = { workspace = true } 20 | blake3 = "1.0.0" 21 | byteorder = "1.0" 22 | diff = "0.1.12" 23 | futures = "0.3" 24 | h264-reader = { workspace = true } 25 | hashlink = "0.10.0" 26 | itertools = { workspace = true } 27 | jiff = { workspace = true } 28 | libc = "0.2" 29 | nix = { workspace = true, features = ["dir", "feature", "fs", "mman"] } 30 | num-rational = { version = "0.4.0", default-features = false, features = [ 31 | "std", 32 | ] } 33 | pretty-hex = { workspace = true } 34 | protobuf = "3.0" 35 | ring = { workspace = true } 36 | rusqlite = { workspace = true } 37 | scrypt = "0.11.0" 38 | serde = { version = "1.0", features = ["derive"] } 39 | serde_json = "1.0" 40 | smallvec = "1.0" 41 | tempfile = "3.2.0" 42 | tokio = { version = "1.24", features = ["macros", "rt-multi-thread", "sync"] } 43 | tracing = { workspace = true } 44 | url = { version = "2.1.1", features = ["serde"] } 45 | uuid = { workspace = true } 46 | 47 | [build-dependencies] 48 | protobuf-codegen = "3.0" 49 | -------------------------------------------------------------------------------- /server/db/build.rs: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2020 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. 4 | 5 | fn main() -> Result<(), Box> { 6 | Ok(protobuf_codegen::Codegen::new() 7 | .pure() 8 | .out_dir(std::env::var("OUT_DIR")?) 9 | .inputs(["proto/schema.proto"]) 10 | .include("proto") 11 | .customize(protobuf_codegen::Customize::default().gen_mod_rs(true)) 12 | .run()?) 13 | } 14 | -------------------------------------------------------------------------------- /server/db/fs.rs: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2019 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. 4 | 5 | //! Filesystem utilities. 6 | 7 | use nix::fcntl::OFlag; 8 | use nix::sys::stat::Mode; 9 | use nix::NixPath; 10 | use std::os::unix::io::{FromRawFd, RawFd}; 11 | 12 | /// Opens the given `path` within `dirfd` with the specified flags. 13 | pub fn openat( 14 | dirfd: RawFd, 15 | path: &P, 16 | oflag: OFlag, 17 | mode: Mode, 18 | ) -> Result { 19 | let fd = nix::fcntl::openat(dirfd, path, oflag, mode)?; 20 | Ok(unsafe { std::fs::File::from_raw_fd(fd) }) 21 | } 22 | -------------------------------------------------------------------------------- /server/db/lib.rs: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. 4 | 5 | //! Moonfire NVR's persistence layer. 6 | //! 7 | //! This manages both the SQLite database and the sample file directory. 8 | //! Everything dealing with either flows through this crate. It keeps in-memory 9 | //! state both as indexes and to batch SQLite database transactions. 10 | //! 11 | //! The core recording design is described in `design/recording.md` and is 12 | //! mostly in the `db` module. 13 | 14 | #![cfg_attr(all(feature = "nightly", test), feature(test))] 15 | 16 | pub mod auth; 17 | pub mod check; 18 | mod coding; 19 | mod compare; 20 | pub mod days; 21 | pub mod db; 22 | pub mod dir; 23 | mod fs; 24 | pub mod json; 25 | mod proto { 26 | include!(concat!(env!("OUT_DIR"), "/mod.rs")); 27 | } 28 | mod raw; 29 | pub mod recording; 30 | pub use proto::schema; 31 | pub mod signal; 32 | pub mod upgrade; 33 | pub mod writer; 34 | 35 | // This is only for #[cfg(test)], but it's also used by the dependent crate, and it appears that 36 | // #[cfg(test)] is not passed on to dependencies. 37 | pub mod testutil; 38 | 39 | pub use crate::db::*; 40 | pub use crate::schema::Permissions; 41 | pub use crate::signal::Signal; 42 | -------------------------------------------------------------------------------- /server/db/proto/schema.proto: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2018 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.'; 4 | 5 | // Protobuf portion of the Moonfire NVR schema. In general Moonfire's schema 6 | // uses a SQLite3 database with some fields in JSON representation. The protobuf 7 | // stuff is just high-cardinality things that must be compact, e.g. permissions 8 | // that can be stuffed into every user session. 9 | 10 | syntax = "proto3"; 11 | 12 | // Metadata stored in sample file dirs as `/meta`. This is checked 13 | // against the metadata stored within the database to detect inconsistencies 14 | // between the directory and database, such as those described in 15 | // `design/schema.md`. 16 | // 17 | // As of schema version 4, the overall file format is as follows: a 18 | // varint-encoded length, followed by a serialized `DirMeta` message, followed 19 | // by NUL bytes padding to a total length of 512 bytes. This message never 20 | // exceeds that length. 21 | // 22 | // The goal of this format is to allow atomically rewriting a meta file 23 | // in-place. I hope that on modern OSs and hardware, a single-sector 24 | // rewrite is atomic, though POSIX frustratingly doesn't seem to guarantee 25 | // this. There's some discussion of that here: 26 | // . At worst, there's a short 27 | // window during which the meta file can be corrupted. As the file's purpose 28 | // is to check for inconsistencies, it can be reconstructed if you assume no 29 | // inconsistency exists. 30 | // 31 | // Schema version 3 wrote a serialized DirMeta message with no length or 32 | // padding, and renamed new meta files over the top of old. This scheme 33 | // requires extra space while opening the directory. If the filesystem is 34 | // completely full, it requires freeing space manually, an undocumented and 35 | // error-prone administrator procedure. 36 | message DirMeta { 37 | // A uuid associated with the database, in binary form. dir_uuid is strictly 38 | // more powerful, but it improves diagnostics to know if the directory 39 | // belongs to the expected database at all or not. 40 | bytes db_uuid = 1; 41 | 42 | // A uuid associated with the directory itself. 43 | bytes dir_uuid = 2; 44 | 45 | // Corresponds to an entry in the `open` database table. 46 | message Open { 47 | uint32 id = 1; 48 | bytes uuid = 2; 49 | } 50 | 51 | // The last open that was known to be recorded in the database as completed. 52 | // Absent if this has never happened. Note this can backtrack in exactly one 53 | // scenario: when deleting the directory, after all associated files have 54 | // been deleted, last_complete_open can be moved to in_progress_open. 55 | Open last_complete_open = 3; 56 | 57 | // The last run which is in progress, if different from last_complete_open. 58 | // This may or may not have been recorded in the database, but it's 59 | // guaranteed that no data has yet been written by this open. 60 | Open in_progress_open = 4; 61 | } 62 | 63 | // Permissions to perform actions. See description in ref/api.md. 64 | // 65 | // This protobuf form is stored in user and session rows. 66 | message Permissions { 67 | bool view_video = 1; 68 | bool read_camera_configs = 2; 69 | bool update_signals = 3; 70 | bool admin_users = 4; 71 | } 72 | -------------------------------------------------------------------------------- /server/db/testdata/avc1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottlamb/moonfire-nvr/104ffdc2dc55bd5115c35328725e9f73efbb9b43/server/db/testdata/avc1 -------------------------------------------------------------------------------- /server/db/testdata/video_sample_index.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottlamb/moonfire-nvr/104ffdc2dc55bd5115c35328725e9f73efbb9b43/server/db/testdata/video_sample_index.bin -------------------------------------------------------------------------------- /server/db/upgrade/v0.sql: -------------------------------------------------------------------------------- 1 | -- This file is part of Moonfire NVR, a security camera network video recorder. 2 | -- Copyright (C) 2016 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | -- SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.'; 4 | 5 | -- schema.sql: SQLite3 database schema for Moonfire NVR. 6 | -- See also design/schema.md. 7 | 8 | --pragma journal_mode = wal; 9 | 10 | -- This table tracks the schema version. 11 | -- There is one row for the initial database creation (inserted below, after the 12 | -- create statements) and one for each upgrade procedure (if any). 13 | create table version ( 14 | id integer primary key, 15 | 16 | -- The unix time as of the creation/upgrade, as determined by 17 | -- cast(strftime('%s', 'now') as int). 18 | unix_time integer not null, 19 | 20 | -- Optional notes on the creation/upgrade; could include the binary version. 21 | notes text 22 | ); 23 | 24 | create table camera ( 25 | id integer primary key, 26 | uuid blob unique,-- not null check (length(uuid) = 16), 27 | 28 | -- A short name of the camera, used in log messages. 29 | short_name text,-- not null, 30 | 31 | -- A short description of the camera. 32 | description text, 33 | 34 | -- The host (or IP address) to use in rtsp:// URLs when accessing the camera. 35 | host text, 36 | 37 | -- The username to use when accessing the camera. 38 | -- If empty, no username or password will be supplied. 39 | username text, 40 | 41 | -- The password to use when accessing the camera. 42 | password text, 43 | 44 | -- The path (starting with "/") to use in rtsp:// URLs to reference this 45 | -- camera's "main" (full-quality) video stream. 46 | main_rtsp_path text, 47 | 48 | -- The path (starting with "/") to use in rtsp:// URLs to reference this 49 | -- camera's "sub" (low-bandwidth) video stream. 50 | sub_rtsp_path text, 51 | 52 | -- The number of bytes of video to retain, excluding the currently-recording 53 | -- file. Older files will be deleted as necessary to stay within this limit. 54 | retain_bytes integer not null check (retain_bytes >= 0) 55 | ); 56 | 57 | -- Each row represents a single completed recorded segment of video. 58 | -- Recordings are typically ~60 seconds; never more than 5 minutes. 59 | create table recording ( 60 | id integer primary key, 61 | camera_id integer references camera (id) not null, 62 | 63 | sample_file_bytes integer not null check (sample_file_bytes > 0), 64 | 65 | -- The starting time of the recording, in 90 kHz units since 66 | -- 1970-01-01 00:00:00 UTC. Currently on initial connection, this is taken 67 | -- from the local system time; on subsequent recordings, it exactly 68 | -- matches the previous recording's end time. 69 | start_time_90k integer not null check (start_time_90k > 0), 70 | 71 | -- The duration of the recording, in 90 kHz units. 72 | duration_90k integer not null 73 | check (duration_90k >= 0 and duration_90k < 5*60*90000), 74 | 75 | -- The number of 90 kHz units the local system time is ahead of the 76 | -- recording; negative numbers indicate the local system time is behind 77 | -- the recording. Large values would indicate that the local time has jumped 78 | -- during recording or that the local time and camera time frequencies do 79 | -- not match. 80 | local_time_delta_90k integer not null, 81 | 82 | video_samples integer not null check (video_samples > 0), 83 | video_sync_samples integer not null check (video_samples > 0), 84 | video_sample_entry_id integer references video_sample_entry (id), 85 | 86 | sample_file_uuid blob not null check (length(sample_file_uuid) = 16), 87 | sample_file_sha1 blob not null check (length(sample_file_sha1) = 20), 88 | video_index blob not null check (length(video_index) > 0) 89 | ); 90 | 91 | create index recording_cover on recording ( 92 | -- Typical queries use "where camera_id = ? order by start_time_90k (desc)?". 93 | camera_id, 94 | start_time_90k, 95 | 96 | -- These fields are not used for ordering; they cover most queries so 97 | -- that only database verification and actual viewing of recordings need 98 | -- to consult the underlying row. 99 | duration_90k, 100 | video_samples, 101 | video_sync_samples, 102 | video_sample_entry_id, 103 | sample_file_bytes 104 | ); 105 | 106 | -- Files in the sample file directory which may be present but should simply be 107 | -- discarded on startup. (Recordings which were never completed or have been 108 | -- marked for completion.) 109 | create table reserved_sample_files ( 110 | uuid blob primary key check (length(uuid) = 16), 111 | state integer not null -- 0 (writing) or 1 (deleted) 112 | ) without rowid; 113 | 114 | -- A concrete box derived from a ISO/IEC 14496-12 section 8.5.2 115 | -- VisualSampleEntry box. Describes the codec, width, height, etc. 116 | create table video_sample_entry ( 117 | id integer primary key, 118 | 119 | -- A SHA-1 hash of |bytes|. 120 | sha1 blob unique not null check (length(sha1) = 20), 121 | 122 | -- The width and height in pixels; must match values within 123 | -- |sample_entry_bytes|. 124 | width integer not null check (width > 0), 125 | height integer not null check (height > 0), 126 | 127 | -- The serialized box, including the leading length and box type (avcC in 128 | -- the case of H.264). 129 | data blob not null check (length(data) > 86) 130 | ); 131 | 132 | insert into version (id, unix_time, notes) 133 | values (0, cast(strftime('%s', 'now') as int), 'db creation'); 134 | -------------------------------------------------------------------------------- /server/db/upgrade/v2_to_v3.rs: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. 4 | 5 | /// Upgrades a version 2 schema to a version 3 schema. 6 | /// Note that a version 2 schema is never actually used; so we know the upgrade from version 1 was 7 | /// completed, and possibly an upgrade from 2 to 3 is half-finished. 8 | use crate::db::{self, SqlUuid}; 9 | use crate::dir; 10 | use crate::schema; 11 | use base::Error; 12 | use rusqlite::params; 13 | use std::os::fd::AsFd as _; 14 | use std::os::unix::io::AsRawFd; 15 | use std::path::PathBuf; 16 | use std::sync::Arc; 17 | 18 | /// Opens the sample file dir. 19 | /// 20 | /// Makes a couple simplifying assumptions valid for version 2: 21 | /// * there's only one dir. 22 | /// * it has a last completed open. 23 | fn open_sample_file_dir(tx: &rusqlite::Transaction) -> Result, Error> { 24 | let (p, s_uuid, o_id, o_uuid, db_uuid): (String, SqlUuid, i32, SqlUuid, SqlUuid) = tx 25 | .query_row( 26 | r#" 27 | select 28 | s.path, s.uuid, s.last_complete_open_id, o.uuid, m.uuid 29 | from 30 | sample_file_dir s 31 | join open o on (s.last_complete_open_id = o.id) 32 | cross join meta m 33 | "#, 34 | params![], 35 | |row| { 36 | Ok(( 37 | row.get(0)?, 38 | row.get(1)?, 39 | row.get(2)?, 40 | row.get(3)?, 41 | row.get(4)?, 42 | )) 43 | }, 44 | )?; 45 | let mut meta = schema::DirMeta::default(); 46 | meta.db_uuid.extend_from_slice(&db_uuid.0.as_bytes()[..]); 47 | meta.dir_uuid.extend_from_slice(&s_uuid.0.as_bytes()[..]); 48 | { 49 | let open = meta.last_complete_open.mut_or_insert_default(); 50 | open.id = o_id as u32; 51 | open.uuid.extend_from_slice(&o_uuid.0.as_bytes()[..]); 52 | } 53 | let p = PathBuf::from(p); 54 | dir::SampleFileDir::open(&p, &meta) 55 | } 56 | 57 | pub fn run(_args: &super::Args, tx: &rusqlite::Transaction) -> Result<(), Error> { 58 | let d = open_sample_file_dir(tx)?; 59 | let mut stmt = tx.prepare( 60 | r#" 61 | select 62 | composite_id, 63 | sample_file_uuid 64 | from 65 | recording_playback 66 | "#, 67 | )?; 68 | let mut rows = stmt.query(params![])?; 69 | while let Some(row) = rows.next()? { 70 | let id = db::CompositeId(row.get(0)?); 71 | let sample_file_uuid: SqlUuid = row.get(1)?; 72 | let from_path = super::UuidPath::from(sample_file_uuid.0); 73 | let to_path = crate::dir::CompositeIdPath::from(id); 74 | if let Err(e) = nix::fcntl::renameat( 75 | Some(d.fd.as_fd().as_raw_fd()), 76 | &from_path, 77 | Some(d.fd.as_fd().as_raw_fd()), 78 | &to_path, 79 | ) { 80 | if e == nix::Error::ENOENT { 81 | continue; // assume it was already moved. 82 | } 83 | return Err(e.into()); 84 | } 85 | } 86 | 87 | // These create statements match the schema.sql when version 3 was the latest. 88 | tx.execute_batch( 89 | r#" 90 | alter table recording_playback rename to old_recording_playback; 91 | create table recording_playback ( 92 | composite_id integer primary key references recording (composite_id), 93 | video_index blob not null check (length(video_index) > 0) 94 | ); 95 | insert into recording_playback 96 | select 97 | composite_id, 98 | video_index 99 | from 100 | old_recording_playback; 101 | drop table old_recording_playback; 102 | drop table old_recording; 103 | drop table old_camera; 104 | drop table old_video_sample_entry; 105 | "#, 106 | )?; 107 | Ok(()) 108 | } 109 | -------------------------------------------------------------------------------- /server/db/upgrade/v4_to_v5.rs: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2019 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. 4 | 5 | /// Upgrades a version 4 schema to a version 5 schema. 6 | /// 7 | /// This just handles the directory meta files. If they're already in the new format, great. 8 | /// Otherwise, verify they are consistent with the database then upgrade them. 9 | use crate::db::SqlUuid; 10 | use crate::{dir, schema}; 11 | use base::{bail, err, Error}; 12 | use nix::fcntl::{FlockArg, OFlag}; 13 | use nix::sys::stat::Mode; 14 | use protobuf::Message; 15 | use rusqlite::params; 16 | use std::io::{Read, Write}; 17 | use std::os::fd::AsFd as _; 18 | use std::os::unix::io::AsRawFd; 19 | use tracing::info; 20 | use uuid::Uuid; 21 | 22 | const FIXED_DIR_META_LEN: usize = 512; 23 | 24 | /// Maybe upgrades the `meta` file, returning if an upgrade happened (and thus a sync is needed). 25 | fn maybe_upgrade_meta(dir: &dir::Fd, db_meta: &schema::DirMeta) -> Result { 26 | let tmp_path = c"meta.tmp"; 27 | let meta_path = c"meta"; 28 | let mut f = crate::fs::openat( 29 | dir.as_fd().as_raw_fd(), 30 | meta_path, 31 | OFlag::O_RDONLY, 32 | Mode::empty(), 33 | )?; 34 | let mut data = Vec::new(); 35 | f.read_to_end(&mut data)?; 36 | if data.len() == FIXED_DIR_META_LEN { 37 | return Ok(false); 38 | } 39 | 40 | let mut s = protobuf::CodedInputStream::from_bytes(&data); 41 | let mut dir_meta = schema::DirMeta::new(); 42 | dir_meta.merge_from(&mut s).map_err(|e| { 43 | err!( 44 | FailedPrecondition, 45 | msg("unable to parse metadata proto"), 46 | source(e) 47 | ) 48 | })?; 49 | if let Err(e) = dir::SampleFileDir::check_consistent(db_meta, &dir_meta) { 50 | bail!( 51 | FailedPrecondition, 52 | msg("inconsistent db_meta={db_meta:?} dir_meta={dir_meta:?}"), 53 | source(e), 54 | ); 55 | } 56 | let mut f = crate::fs::openat( 57 | dir.as_fd().as_raw_fd(), 58 | tmp_path, 59 | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_WRONLY, 60 | Mode::S_IRUSR | Mode::S_IWUSR, 61 | )?; 62 | let mut data = dir_meta 63 | .write_length_delimited_to_bytes() 64 | .expect("proto3->vec is infallible"); 65 | if data.len() > FIXED_DIR_META_LEN { 66 | bail!( 67 | Internal, 68 | msg( 69 | "length-delimited DirMeta message requires {} bytes, over limit of {}", 70 | data.len(), 71 | FIXED_DIR_META_LEN, 72 | ), 73 | ); 74 | } 75 | data.resize(FIXED_DIR_META_LEN, 0); // pad to required length. 76 | f.write_all(&data)?; 77 | f.sync_all()?; 78 | 79 | nix::fcntl::renameat( 80 | Some(dir.as_fd().as_raw_fd()), 81 | tmp_path, 82 | Some(dir.as_fd().as_raw_fd()), 83 | meta_path, 84 | )?; 85 | Ok(true) 86 | } 87 | 88 | /// Looks for uuid-based filenames and deletes them. 89 | /// 90 | /// The v1->v3 migration failed to remove garbage files prior to 433be217. Let's have a clean slate 91 | /// at v5. 92 | /// 93 | /// Returns true if something was done (and thus a sync is needed). 94 | fn maybe_cleanup_garbage_uuids(dir: &dir::Fd) -> Result { 95 | let mut need_sync = false; 96 | let mut dir2 = nix::dir::Dir::openat( 97 | dir.as_fd().as_raw_fd(), 98 | ".", 99 | OFlag::O_DIRECTORY | OFlag::O_RDONLY, 100 | Mode::empty(), 101 | )?; 102 | for e in dir2.iter() { 103 | let e = e?; 104 | let f = e.file_name(); 105 | info!("file: {}", f.to_str().unwrap()); 106 | let f_str = match f.to_str() { 107 | Ok(f) => f, 108 | Err(_) => continue, 109 | }; 110 | if Uuid::parse_str(f_str).is_ok() { 111 | info!("removing leftover garbage file {}", f_str); 112 | nix::unistd::unlinkat( 113 | Some(dir.as_fd().as_raw_fd()), 114 | f, 115 | nix::unistd::UnlinkatFlags::NoRemoveDir, 116 | )?; 117 | need_sync = true; 118 | } 119 | } 120 | 121 | Ok(need_sync) 122 | } 123 | 124 | pub fn run(_args: &super::Args, tx: &rusqlite::Transaction) -> Result<(), Error> { 125 | let db_uuid: SqlUuid = 126 | tx.query_row_and_then(r"select uuid from meta", params![], |row| row.get(0))?; 127 | let mut stmt = tx.prepare( 128 | r#" 129 | select 130 | d.path, 131 | d.uuid, 132 | d.last_complete_open_id, 133 | o.uuid 134 | from 135 | sample_file_dir d 136 | left join open o on (d.last_complete_open_id = o.id); 137 | "#, 138 | )?; 139 | let mut rows = stmt.query(params![])?; 140 | while let Some(row) = rows.next()? { 141 | let path = row.get_ref(0)?.as_str()?; 142 | info!("path: {}", path); 143 | let dir_uuid: SqlUuid = row.get(1)?; 144 | let open_id: Option = row.get(2)?; 145 | let open_uuid: Option = row.get(3)?; 146 | let mut db_meta = schema::DirMeta::new(); 147 | db_meta.db_uuid.extend_from_slice(&db_uuid.0.as_bytes()[..]); 148 | db_meta 149 | .dir_uuid 150 | .extend_from_slice(&dir_uuid.0.as_bytes()[..]); 151 | match (open_id, open_uuid) { 152 | (Some(id), Some(uuid)) => { 153 | let o = db_meta.last_complete_open.mut_or_insert_default(); 154 | o.id = id; 155 | o.uuid.extend_from_slice(&uuid.0.as_bytes()[..]); 156 | } 157 | (None, None) => {} 158 | _ => bail!(Internal, msg("open table missing id")), 159 | } 160 | 161 | let dir = dir::Fd::open(path, false)?; 162 | dir.lock(FlockArg::LockExclusiveNonblock) 163 | .map_err(|e| err!(e, msg("unable to lock dir {path}")))?; 164 | 165 | let mut need_sync = maybe_upgrade_meta(&dir, &db_meta)?; 166 | if maybe_cleanup_garbage_uuids(&dir)? { 167 | need_sync = true; 168 | } 169 | 170 | if need_sync { 171 | dir.sync()?; 172 | } 173 | info!("done with path: {}", path); 174 | } 175 | Ok(()) 176 | } 177 | -------------------------------------------------------------------------------- /server/src/body.rs: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | //! HTTP body implementation using `ARefss<'static, [u8]>` chunks. 6 | //! 7 | //! Moonfire NVR uses this custom chunk type rather than [bytes::Bytes]. This 8 | //! is mostly for historical reasons: we used to use `mmap`-backed chunks. 9 | //! The custom chunk type also helps minimize reference-counting in `mp4::File` 10 | //! as described [here](https://github.com/tokio-rs/bytes/issues/359#issuecomment-640812016), 11 | //! although this is a pretty small optimization. 12 | //! 13 | //! Some day I expect [bytes::Bytes] will expose its vtable (see link above), 14 | //! allowing us to minimize reference-counting without a custom chunk type. 15 | 16 | use base::Error; 17 | use reffers::ARefss; 18 | use std::error::Error as StdError; 19 | 20 | pub struct Chunk(ARefss<'static, [u8]>); 21 | 22 | pub type BoxedError = Box; 23 | 24 | pub fn wrap_error(e: Error) -> BoxedError { 25 | Box::new(e) 26 | } 27 | 28 | impl From> for Chunk { 29 | fn from(r: ARefss<'static, [u8]>) -> Self { 30 | Chunk(r) 31 | } 32 | } 33 | 34 | impl From<&'static [u8]> for Chunk { 35 | fn from(r: &'static [u8]) -> Self { 36 | Chunk(ARefss::new(r)) 37 | } 38 | } 39 | 40 | impl From<&'static str> for Chunk { 41 | fn from(r: &'static str) -> Self { 42 | Chunk(ARefss::new(r.as_bytes())) 43 | } 44 | } 45 | 46 | impl From for Chunk { 47 | fn from(r: String) -> Self { 48 | Chunk(ARefss::new(r.into_bytes())) 49 | } 50 | } 51 | 52 | impl From> for Chunk { 53 | fn from(r: Vec) -> Self { 54 | Chunk(ARefss::new(r)) 55 | } 56 | } 57 | 58 | impl hyper::body::Buf for Chunk { 59 | fn remaining(&self) -> usize { 60 | self.0.len() 61 | } 62 | fn chunk(&self) -> &[u8] { 63 | &self.0 64 | } 65 | fn advance(&mut self, cnt: usize) { 66 | self.0 = ::std::mem::replace(&mut self.0, ARefss::new(&[][..])).map(|b| &b[cnt..]); 67 | } 68 | } 69 | 70 | pub type Body = http_serve::Body; 71 | -------------------------------------------------------------------------------- /server/src/cmds/check.rs: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2020 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. 4 | 5 | //! Subcommand to check the database and sample file dir for errors. 6 | 7 | use base::Error; 8 | use bpaf::Bpaf; 9 | use db::check; 10 | use std::path::PathBuf; 11 | 12 | /// Checks database integrity (like fsck). 13 | #[derive(Bpaf, Debug)] 14 | #[bpaf(command("check"))] 15 | pub struct Args { 16 | #[bpaf(external(crate::parse_db_dir))] 17 | db_dir: PathBuf, 18 | 19 | /// Compares sample file lengths on disk to the database. 20 | compare_lens: bool, 21 | 22 | /// Trashes sample files without matching recording rows in the database. 23 | /// This addresses `Missing ... row` errors. The ids are added to the 24 | /// `garbage` table to indicate the files need to be deleted. Garbage is 25 | /// collected on normal startup. 26 | trash_orphan_sample_files: bool, 27 | 28 | /// Deletes recording rows in the database without matching sample files. 29 | /// This addresses `Recording ... missing file` errors. 30 | delete_orphan_rows: bool, 31 | 32 | /// Trashes recordings when their database rows appear corrupt. 33 | /// This addresses "bad video_index" errors. The ids are added to the 34 | /// `garbage` table to indicate their files need to be deleted. Garbage is 35 | /// collected on normal startup. 36 | trash_corrupt_rows: bool, 37 | } 38 | 39 | pub fn run(args: Args) -> Result { 40 | let (_db_dir, mut conn) = super::open_conn(&args.db_dir, super::OpenMode::ReadWrite)?; 41 | check::run( 42 | &mut conn, 43 | &check::Options { 44 | compare_lens: args.compare_lens, 45 | trash_orphan_sample_files: args.trash_orphan_sample_files, 46 | delete_orphan_rows: args.delete_orphan_rows, 47 | trash_corrupt_rows: args.trash_corrupt_rows, 48 | }, 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /server/src/cmds/config/mod.rs: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2017 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. 4 | 5 | //! Text-based configuration interface. 6 | //! 7 | //! This code is a bit messy, but it's essentially a prototype. Eventually Moonfire NVR's 8 | //! configuration will likely be almost entirely done through a web-based UI. 9 | 10 | use base::clock; 11 | use base::Error; 12 | use bpaf::Bpaf; 13 | use cursive::views; 14 | use cursive::Cursive; 15 | use std::path::PathBuf; 16 | use std::sync::Arc; 17 | 18 | mod cameras; 19 | mod dirs; 20 | mod tab_complete; 21 | mod users; 22 | 23 | /// Interactively edits configuration. 24 | #[derive(Bpaf, Debug)] 25 | #[bpaf(command("config"))] 26 | pub struct Args { 27 | #[bpaf(external(crate::parse_db_dir))] 28 | db_dir: PathBuf, 29 | } 30 | 31 | pub fn run(args: Args) -> Result { 32 | let (_db_dir, conn) = super::open_conn(&args.db_dir, super::OpenMode::ReadWrite)?; 33 | let clocks = clock::RealClocks {}; 34 | let db = Arc::new(db::Database::new(clocks, conn, true)?); 35 | 36 | // This runtime is needed by the "Test" button in the camera config. 37 | let rt = tokio::runtime::Builder::new_multi_thread() 38 | .enable_io() 39 | .enable_time() 40 | .build()?; 41 | let _enter = rt.enter(); 42 | 43 | let mut siv = cursive::default(); 44 | //siv.add_global_callback('q', |s| s.quit()); 45 | 46 | siv.add_layer( 47 | views::Dialog::around( 48 | views::SelectView::, &mut Cursive)>::new() 49 | .on_submit(move |siv, item| item(&db, siv)) 50 | .item("Cameras and streams", cameras::top_dialog) 51 | .item("Directories and retention", dirs::top_dialog) 52 | .item("Users", users::top_dialog), 53 | ) 54 | .button("Quit", |siv| siv.quit()) 55 | .title("Main menu"), 56 | ); 57 | 58 | siv.run(); 59 | 60 | Ok(0) 61 | } 62 | -------------------------------------------------------------------------------- /server/src/cmds/config/tab_complete.rs: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2020 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. 4 | 5 | use base::Mutex; 6 | use std::sync::Arc; 7 | 8 | use cursive::{ 9 | direction::Direction, 10 | event::{Event, EventResult, Key}, 11 | menu, 12 | view::CannotFocus, 13 | views::{self, EditView, MenuPopup}, 14 | Printer, Rect, Vec2, View, With, 15 | }; 16 | 17 | type TabCompleteFn = Arc Vec + Send + Sync>; 18 | 19 | pub struct TabCompleteEditView { 20 | edit_view: Arc>, 21 | tab_completer: Option, 22 | } 23 | 24 | impl TabCompleteEditView { 25 | pub fn new(edit_view: EditView) -> Self { 26 | Self { 27 | edit_view: Arc::new(Mutex::new(edit_view)), 28 | tab_completer: None, 29 | } 30 | } 31 | 32 | pub fn on_tab_complete( 33 | mut self, 34 | handler: impl Fn(&str) -> Vec + Send + Sync + 'static, 35 | ) -> Self { 36 | self.tab_completer = Some(Arc::new(handler)); 37 | self 38 | } 39 | 40 | pub fn get_content(&self) -> Arc { 41 | self.edit_view.lock().get_content() 42 | } 43 | } 44 | 45 | impl View for TabCompleteEditView { 46 | fn draw(&self, printer: &Printer) { 47 | self.edit_view.lock().draw(printer) 48 | } 49 | 50 | fn layout(&mut self, size: Vec2) { 51 | self.edit_view.lock().layout(size) 52 | } 53 | 54 | fn take_focus(&mut self, source: Direction) -> Result { 55 | self.edit_view.lock().take_focus(source) 56 | } 57 | 58 | fn on_event(&mut self, event: Event) -> EventResult { 59 | if !self.edit_view.lock().is_enabled() { 60 | return EventResult::Ignored; 61 | } 62 | 63 | if let Event::Key(Key::Tab) = event { 64 | if let Some(tab_completer) = self.tab_completer.clone() { 65 | tab_complete(self.edit_view.clone(), tab_completer, true) 66 | } else { 67 | EventResult::consumed() 68 | } 69 | } else { 70 | self.edit_view.lock().on_event(event) 71 | } 72 | } 73 | 74 | fn important_area(&self, view_size: Vec2) -> Rect { 75 | self.edit_view.lock().important_area(view_size) 76 | } 77 | } 78 | 79 | fn tab_complete( 80 | edit_view: Arc>, 81 | tab_completer: TabCompleteFn, 82 | autofill_one: bool, 83 | ) -> EventResult { 84 | let completions = tab_completer(edit_view.lock().get_content().as_str()); 85 | EventResult::with_cb_once(move |siv| match *completions { 86 | [] => {} 87 | [ref completion] if autofill_one => edit_view.lock().set_content(completion)(siv), 88 | [..] => { 89 | siv.add_layer(TabCompletePopup { 90 | popup: views::MenuPopup::new(Arc::new({ 91 | menu::Tree::new().with(|tree| { 92 | for completion in completions { 93 | let edit_view = edit_view.clone(); 94 | tree.add_leaf(completion.clone(), move |siv| { 95 | edit_view.lock().set_content(&completion)(siv) 96 | }) 97 | } 98 | }) 99 | })), 100 | edit_view, 101 | tab_completer, 102 | }); 103 | } 104 | }) 105 | } 106 | 107 | struct TabCompletePopup { 108 | edit_view: Arc>, 109 | popup: MenuPopup, 110 | tab_completer: TabCompleteFn, 111 | } 112 | impl TabCompletePopup { 113 | fn forward_event_and_refresh(&self, event: Event) -> EventResult { 114 | let edit_view = self.edit_view.clone(); 115 | let tab_completer = self.tab_completer.clone(); 116 | EventResult::with_cb_once(move |s| { 117 | s.pop_layer(); 118 | edit_view.lock().on_event(event).process(s); 119 | tab_complete(edit_view, tab_completer, false).process(s); 120 | }) 121 | } 122 | } 123 | 124 | impl View for TabCompletePopup { 125 | fn draw(&self, printer: &Printer) { 126 | self.popup.draw(printer) 127 | } 128 | 129 | fn required_size(&mut self, req: Vec2) -> Vec2 { 130 | self.popup.required_size(req) 131 | } 132 | 133 | fn on_event(&mut self, event: Event) -> EventResult { 134 | match self.popup.on_event(event.clone()) { 135 | EventResult::Ignored => match event { 136 | e @ (Event::Char(_) | Event::Key(Key::Backspace)) => { 137 | self.forward_event_and_refresh(e) 138 | } 139 | Event::Key(Key::Tab) => self.popup.on_event(Event::Key(Key::Enter)), 140 | _ => EventResult::Ignored, 141 | }, 142 | other => other, 143 | } 144 | } 145 | 146 | fn layout(&mut self, size: Vec2) { 147 | self.popup.layout(size) 148 | } 149 | 150 | fn important_area(&self, size: Vec2) -> Rect { 151 | self.popup.important_area(size) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /server/src/cmds/init.rs: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2020 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. 4 | 5 | use base::Error; 6 | use bpaf::Bpaf; 7 | use std::path::PathBuf; 8 | use tracing::info; 9 | 10 | /// Initializes a database. 11 | #[derive(Bpaf, Debug)] 12 | #[bpaf(command("init"))] 13 | pub struct Args { 14 | #[bpaf(external(crate::parse_db_dir))] 15 | db_dir: PathBuf, 16 | } 17 | 18 | pub fn run(args: Args) -> Result { 19 | let (_db_dir, mut conn) = super::open_conn(&args.db_dir, super::OpenMode::Create)?; 20 | 21 | // Check if the database has already been initialized. 22 | let cur_ver = db::get_schema_version(&conn)?; 23 | if let Some(v) = cur_ver { 24 | info!("Database is already initialized with schema version {}.", v); 25 | return Ok(0); 26 | } 27 | 28 | // Use WAL mode (which is the most efficient way to preserve database integrity) with a large 29 | // page size (so reading large recording_playback rows doesn't require as many seeks). Changing 30 | // the page size requires doing a vacuum in non-WAL mode. This will be cheap on an empty 31 | // database. https://www.sqlite.org/pragma.html#pragma_page_size 32 | conn.execute_batch( 33 | r#" 34 | pragma journal_mode = delete; 35 | pragma page_size = 16384; 36 | vacuum; 37 | pragma journal_mode = wal; 38 | "#, 39 | )?; 40 | db::init(&mut conn)?; 41 | info!("Database initialized."); 42 | Ok(0) 43 | } 44 | -------------------------------------------------------------------------------- /server/src/cmds/mod.rs: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2016 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. 4 | 5 | use base::{err, Error}; 6 | use db::dir; 7 | use nix::fcntl::FlockArg; 8 | use std::path::Path; 9 | use tracing::info; 10 | 11 | pub mod check; 12 | pub mod config; 13 | pub mod init; 14 | pub mod login; 15 | pub mod run; 16 | pub mod sql; 17 | pub mod ts; 18 | pub mod upgrade; 19 | 20 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] 21 | enum OpenMode { 22 | ReadOnly, 23 | ReadWrite, 24 | Create, 25 | } 26 | 27 | /// Locks the directory without opening the database. 28 | /// The returned `dir::Fd` holds the lock and should be kept open as long as the `Connection` is. 29 | fn open_dir(db_dir: &Path, mode: OpenMode) -> Result { 30 | let dir = dir::Fd::open(db_dir, mode == OpenMode::Create).map_err(|e| { 31 | if mode == OpenMode::Create { 32 | err!(e, msg("unable to create db dir {}", db_dir.display())) 33 | } else if e == nix::Error::ENOENT { 34 | err!( 35 | NotFound, 36 | msg( 37 | "db dir {} not found; try running moonfire-nvr init", 38 | db_dir.display(), 39 | ), 40 | ) 41 | } else { 42 | err!(e, msg("unable to open db dir {}", db_dir.display())) 43 | } 44 | })?; 45 | let ro = mode == OpenMode::ReadOnly; 46 | dir.lock(if ro { 47 | FlockArg::LockSharedNonblock 48 | } else { 49 | FlockArg::LockExclusiveNonblock 50 | }) 51 | .map_err(|e| { 52 | err!( 53 | e, 54 | msg( 55 | "unable to get {} lock on db dir {} ", 56 | if ro { "shared" } else { "exclusive" }, 57 | db_dir.display(), 58 | ), 59 | ) 60 | })?; 61 | Ok(dir) 62 | } 63 | 64 | /// Locks and opens the database. 65 | /// The returned `dir::Fd` holds the lock and should be kept open as long as the `Connection` is. 66 | fn open_conn(db_dir: &Path, mode: OpenMode) -> Result<(dir::Fd, rusqlite::Connection), Error> { 67 | let dir = open_dir(db_dir, mode)?; 68 | let db_path = db_dir.join("db"); 69 | info!( 70 | "Opening {} in {:?} mode with SQLite version {}", 71 | db_path.display(), 72 | mode, 73 | rusqlite::version() 74 | ); 75 | let conn = rusqlite::Connection::open_with_flags_and_vfs( 76 | db_path, 77 | match mode { 78 | OpenMode::ReadOnly => rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, 79 | OpenMode::ReadWrite => rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE, 80 | OpenMode::Create => { 81 | rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_CREATE 82 | }, 83 | } | 84 | // `rusqlite::Connection` is not Sync, so there's no reason to tell SQLite3 to use the 85 | // serialized threading mode. 86 | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, 87 | // In read/write mode, Moonfire holds a directory lock for its entire operation, as 88 | // described above. There's then no point in SQLite releasing its lock after each 89 | // transaction and reacquiring it, or in using shared memory for the wal-index. 90 | // See the following page: 91 | match mode { 92 | OpenMode::ReadOnly => "unix", 93 | _ => "unix-excl", 94 | }, 95 | )?; 96 | Ok((dir, conn)) 97 | } 98 | 99 | #[cfg(test)] 100 | mod tests { 101 | use super::*; 102 | 103 | #[test] 104 | fn open_dir_error_msg() { 105 | let tmpdir = tempfile::Builder::new() 106 | .prefix("moonfire-nvr-test") 107 | .tempdir() 108 | .unwrap(); 109 | let mut nonexistent_dir = tmpdir.path().to_path_buf(); 110 | nonexistent_dir.push("nonexistent"); 111 | let nonexistent_open = open_dir(&nonexistent_dir, OpenMode::ReadOnly).unwrap_err(); 112 | assert!( 113 | nonexistent_open 114 | .to_string() 115 | .contains("try running moonfire-nvr init"), 116 | "unexpected error {}", 117 | &nonexistent_open 118 | ); 119 | } 120 | 121 | #[test] 122 | fn create_dir_error_msg() { 123 | let tmpdir = tempfile::Builder::new() 124 | .prefix("moonfire-nvr-test") 125 | .tempdir() 126 | .unwrap(); 127 | let mut nonexistent_dir = tmpdir.path().to_path_buf(); 128 | nonexistent_dir.push("nonexistent"); 129 | nonexistent_dir.push("db"); 130 | let nonexistent_create = open_dir(&nonexistent_dir, OpenMode::Create).unwrap_err(); 131 | assert!( 132 | nonexistent_create.to_string().contains("unable to create"), 133 | "unexpected error {}", 134 | &nonexistent_create 135 | ); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /server/src/cmds/run/config.rs: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2022 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. 4 | 5 | //! Runtime configuration file (`/etc/moonfire-nvr.toml`). 6 | //! See `ref/config.md` for more description. 7 | 8 | use std::path::PathBuf; 9 | 10 | use serde::Deserialize; 11 | 12 | use crate::json::Permissions; 13 | 14 | fn default_db_dir() -> PathBuf { 15 | crate::DEFAULT_DB_DIR.into() 16 | } 17 | 18 | /// Top-level configuration file object. 19 | #[derive(Debug, Deserialize)] 20 | #[serde(deny_unknown_fields)] 21 | #[serde(rename_all = "camelCase")] 22 | pub struct ConfigFile { 23 | pub binds: Vec, 24 | 25 | /// Directory holding the SQLite3 index database. 26 | /// 27 | /// default: `/var/lib/moonfire-nvr/db`. 28 | #[serde(default = "default_db_dir")] 29 | pub db_dir: PathBuf, 30 | 31 | /// Directory holding user interface files (`.html`, `.js`, etc). 32 | #[cfg_attr(not(feature = "bundled-ui"), serde(default))] 33 | #[cfg_attr(feature = "bundled-ui", serde(default))] 34 | pub ui_dir: UiDir, 35 | 36 | /// The number of worker threads used by the asynchronous runtime. 37 | /// 38 | /// Defaults to the number of cores on the system. 39 | #[serde(default)] 40 | pub worker_threads: Option, 41 | } 42 | 43 | #[derive(Debug, Deserialize)] 44 | #[serde(rename_all = "camelCase", untagged)] 45 | pub enum UiDir { 46 | FromFilesystem(PathBuf), 47 | Bundled(#[allow(unused)] BundledUi), 48 | } 49 | 50 | impl Default for UiDir { 51 | #[cfg(feature = "bundled-ui")] 52 | fn default() -> Self { 53 | UiDir::Bundled(BundledUi { bundled: true }) 54 | } 55 | 56 | #[cfg(not(feature = "bundled-ui"))] 57 | fn default() -> Self { 58 | UiDir::FromFilesystem("/usr/local/lib/moonfire-nvr/ui".into()) 59 | } 60 | } 61 | 62 | #[derive(Debug, Deserialize)] 63 | #[serde(deny_unknown_fields)] 64 | #[serde(rename_all = "camelCase")] 65 | pub struct BundledUi { 66 | /// Just a marker to select this variant. 67 | #[allow(unused)] 68 | bundled: bool, 69 | } 70 | 71 | /// Per-bind configuration. 72 | #[derive(Debug, Deserialize)] 73 | #[serde(deny_unknown_fields)] 74 | #[serde(rename_all = "camelCase")] 75 | pub struct BindConfig { 76 | /// The address to bind to. 77 | #[serde(flatten)] 78 | pub address: AddressConfig, 79 | 80 | /// Allow unauthenticated API access on this bind, with the given 81 | /// permissions (defaults to empty). 82 | /// 83 | /// Note that even an empty string allows some basic access that would be rejected if the 84 | /// argument were omitted. 85 | #[serde(default)] 86 | pub allow_unauthenticated_permissions: Option, 87 | 88 | /// Trusts `X-Real-IP:` and `X-Forwarded-Proto:` headers on the incoming request. 89 | /// 90 | /// Set this only after ensuring your proxy server is configured to set them 91 | /// and that no untrusted requests bypass the proxy server. You may want to 92 | /// specify a localhost bind address. 93 | #[serde(default)] 94 | pub trust_forward_headers: bool, 95 | 96 | /// On Unix-domain sockets, treat clients with the Moonfire NVR server's own 97 | /// effective UID as privileged. 98 | #[serde(default)] 99 | pub own_uid_is_privileged: bool, 100 | } 101 | 102 | #[derive(Clone, Debug, Deserialize)] 103 | #[serde(deny_unknown_fields)] 104 | #[serde(rename_all = "camelCase")] 105 | pub enum AddressConfig { 106 | /// IPv4 address such as `0.0.0.0:8080` or `127.0.0.1:8080`. 107 | Ipv4(std::net::SocketAddrV4), 108 | 109 | /// IPv6 address such as `[::]:8080` or `[::1]:8080`. 110 | Ipv6(std::net::SocketAddrV6), 111 | 112 | /// Unix socket path such as `/var/lib/moonfire-nvr/sock`. 113 | Unix(PathBuf), 114 | 115 | /// `systemd` socket activation. 116 | /// 117 | /// See [systemd.socket(5) manual 118 | /// page](https://www.freedesktop.org/software/systemd/man/systemd.socket.html). 119 | Systemd(#[cfg_attr(not(target_os = "linux"), allow(unused))] String), 120 | } 121 | 122 | impl std::fmt::Display for AddressConfig { 123 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 124 | match self { 125 | AddressConfig::Ipv4(addr) => write!(f, "ipv4:{}", addr), 126 | AddressConfig::Ipv6(addr) => write!(f, "ipv6:{}", addr), 127 | AddressConfig::Unix(path) => write!(f, "unix:{}", path.display()), 128 | AddressConfig::Systemd(name) => write!(f, "systemd:{name}"), 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /server/src/cmds/sql.rs: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2020 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. 4 | 5 | //! Subcommand to run a SQLite shell. 6 | 7 | use super::OpenMode; 8 | use base::Error; 9 | use bpaf::Bpaf; 10 | use std::ffi::OsString; 11 | use std::os::unix::process::CommandExt; 12 | use std::path::PathBuf; 13 | use std::process::Command; 14 | 15 | /// Runs a SQLite3 shell on Moonfire NVR's index database. 16 | /// 17 | /// 18 | /// Note this locks the database to prevent simultaneous access with a running server. The 19 | /// server maintains cached state which could be invalidated otherwise. 20 | #[derive(Bpaf, Debug, PartialEq, Eq)] 21 | #[bpaf(command("sql"))] 22 | pub struct Args { 23 | #[bpaf(external(crate::parse_db_dir))] 24 | db_dir: PathBuf, 25 | 26 | /// Opens the database in read-only mode and locks it only for shared access. 27 | /// This can be run simultaneously with `moonfire-nvr run --read-only`. 28 | read_only: bool, 29 | 30 | /// Arguments to pass to sqlite3. 31 | /// Use the `--` separator to pass sqlite3 options, as in 32 | /// `moonfire-nvr sql -- -line 'select username from user'`. 33 | #[bpaf(positional)] 34 | arg: Vec, 35 | } 36 | 37 | pub fn run(args: Args) -> Result { 38 | let mode = if args.read_only { 39 | OpenMode::ReadOnly 40 | } else { 41 | OpenMode::ReadWrite 42 | }; 43 | let _db_dir = super::open_dir(&args.db_dir, mode)?; 44 | let mut db = OsString::new(); 45 | db.push("file:"); 46 | db.push(&args.db_dir); 47 | db.push("/db"); 48 | if args.read_only { 49 | db.push("?mode=ro"); 50 | } 51 | Err(Command::new("sqlite3") 52 | .args(db::db::INTEGRITY_PRAGMAS.iter().flat_map(|p| ["-cmd", p])) 53 | .arg(&db) 54 | .args(&args.arg) 55 | .exec() 56 | .into()) 57 | } 58 | 59 | #[cfg(test)] 60 | mod tests { 61 | use super::*; 62 | use bpaf::Parser; 63 | 64 | #[test] 65 | fn parse_args() { 66 | let args = args() 67 | .to_options() 68 | .run_inner(bpaf::Args::from(&[ 69 | "sql", 70 | "--db-dir", 71 | "/foo/bar", 72 | "--", 73 | "-line", 74 | "select username from user", 75 | ])) 76 | .unwrap(); 77 | assert_eq!( 78 | args, 79 | Args { 80 | db_dir: "/foo/bar".into(), 81 | read_only: false, // default 82 | arg: vec!["-line".into(), "select username from user".into()], 83 | } 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /server/src/cmds/ts.rs: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2020 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. 4 | 5 | use base::Error; 6 | use bpaf::Bpaf; 7 | 8 | /// Translates between integer and human-readable timestamps. 9 | #[derive(Bpaf, Debug)] 10 | #[bpaf(command("ts"))] 11 | pub struct Args { 12 | /// Timestamp(s) to translate. 13 | /// 14 | /// May be either an integer or an RFC-3339-like string: 15 | /// `YYYY-mm-dd[THH:MM[:SS[:FFFFF]]][{Z,{+,-,}HH:MM}]`. 16 | /// 17 | /// E.g.: `142913484000000`, `2020-04-26`, `2020-04-26T12:00:00:00000-07:00`. 18 | #[bpaf(positional("TS"), some("must specify at least one timestamp"))] 19 | timestamps: Vec, 20 | } 21 | 22 | pub fn run(args: Args) -> Result { 23 | for timestamp in &args.timestamps { 24 | let t = db::recording::Time::parse(timestamp)?; 25 | println!("{} == {}", t, t.0); 26 | } 27 | Ok(0) 28 | } 29 | -------------------------------------------------------------------------------- /server/src/cmds/upgrade/mod.rs: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2020 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. 4 | 5 | /// Upgrades the database schema. 6 | /// 7 | /// See `guide/schema.md` for more information. 8 | use base::Error; 9 | use bpaf::Bpaf; 10 | 11 | /// Upgrades to the latest database schema. 12 | #[derive(Bpaf, Debug)] 13 | #[bpaf(command("upgrade"))] 14 | pub struct Args { 15 | #[bpaf(external(crate::parse_db_dir))] 16 | db_dir: std::path::PathBuf, 17 | 18 | /// When upgrading from schema version 1 to 2, the sample file directory. 19 | #[bpaf(argument("PATH"))] 20 | sample_file_dir: Option, 21 | 22 | /// Resets the SQLite journal_mode to the specified mode prior to 23 | /// the upgrade. `off` is very dangerous but may be desirable in some 24 | /// circumstances. See `guide/schema.md` for more information. The journal 25 | /// mode will be reset to `wal` after the upgrade. 26 | #[bpaf(argument("MODE"), fallback("delete".to_owned()), debug_fallback)] 27 | preset_journal: String, 28 | 29 | /// Skips the normal post-upgrade vacuum operation. 30 | no_vacuum: bool, 31 | } 32 | 33 | pub fn run(args: Args) -> Result { 34 | let (_db_dir, mut conn) = super::open_conn(&args.db_dir, super::OpenMode::ReadWrite)?; 35 | 36 | db::upgrade::run( 37 | &db::upgrade::Args { 38 | sample_file_dir: args.sample_file_dir.as_deref(), 39 | preset_journal: &args.preset_journal, 40 | no_vacuum: args.no_vacuum, 41 | }, 42 | crate::VERSION, 43 | &mut conn, 44 | )?; 45 | Ok(0) 46 | } 47 | -------------------------------------------------------------------------------- /server/src/main.rs: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. 4 | 5 | #![cfg_attr(all(feature = "nightly", test), feature(test))] 6 | 7 | use base::Error; 8 | use bpaf::{Bpaf, Parser}; 9 | use std::ffi::OsStr; 10 | use std::path::{Path, PathBuf}; 11 | use tracing::{debug, error}; 12 | 13 | mod body; 14 | mod cmds; 15 | mod json; 16 | mod mp4; 17 | mod slices; 18 | mod stream; 19 | mod streamer; 20 | mod web; 21 | 22 | #[cfg(feature = "bundled-ui")] 23 | mod bundled_ui; 24 | 25 | const DEFAULT_DB_DIR: &str = "/var/lib/moonfire-nvr/db"; 26 | 27 | // This is either in the environment when `cargo` is invoked or set from within `build.rs`. 28 | const VERSION: &str = env!("VERSION"); 29 | 30 | /// Moonfire NVR: security camera network video recorder. 31 | #[derive(Bpaf, Debug)] 32 | #[bpaf(options, version(VERSION))] 33 | enum Args { 34 | // See docstrings of `cmds::*::Args` structs for a description of the respective subcommands. 35 | Check(#[bpaf(external(cmds::check::args))] cmds::check::Args), 36 | Config(#[bpaf(external(cmds::config::args))] cmds::config::Args), 37 | Init(#[bpaf(external(cmds::init::args))] cmds::init::Args), 38 | Login(#[bpaf(external(cmds::login::args))] cmds::login::Args), 39 | Run(#[bpaf(external(cmds::run::args))] cmds::run::Args), 40 | Sql(#[bpaf(external(cmds::sql::args))] cmds::sql::Args), 41 | Ts(#[bpaf(external(cmds::ts::args))] cmds::ts::Args), 42 | Upgrade(#[bpaf(external(cmds::upgrade::args))] cmds::upgrade::Args), 43 | } 44 | 45 | impl Args { 46 | fn run(self) -> Result { 47 | match self { 48 | Args::Check(a) => cmds::check::run(a), 49 | Args::Config(a) => cmds::config::run(a), 50 | Args::Init(a) => cmds::init::run(a), 51 | Args::Login(a) => cmds::login::run(a), 52 | Args::Run(a) => cmds::run::run(a), 53 | Args::Sql(a) => cmds::sql::run(a), 54 | Args::Ts(a) => cmds::ts::run(a), 55 | Args::Upgrade(a) => cmds::upgrade::run(a), 56 | } 57 | } 58 | } 59 | 60 | fn parse_db_dir() -> impl Parser { 61 | bpaf::long("db-dir") 62 | .help("Directory holding the SQLite3 index database.") 63 | .argument::("PATH") 64 | .fallback(DEFAULT_DB_DIR.into()) 65 | .debug_fallback() 66 | } 67 | 68 | fn main() { 69 | // If using the clock will fail, find out now *before* trying to log 70 | // anything (with timestamps...) so we can print a helpful error. 71 | if let Err(e) = nix::time::clock_gettime(nix::time::ClockId::CLOCK_MONOTONIC) { 72 | eprintln!( 73 | "clock_gettime(CLOCK_MONOTONIC) failed: {e}\n\n\ 74 | This indicates a broken environment. See the troubleshooting guide." 75 | ); 76 | std::process::exit(1); 77 | } 78 | base::tracing_setup::install(); 79 | base::time::init_zone(jiff::tz::TimeZone::system); 80 | base::ensure_malloc_used(); 81 | 82 | // Get the program name from the OS (e.g. if invoked as `target/debug/nvr`: `nvr`), 83 | // falling back to the crate name if conversion to a path/UTF-8 string fails. 84 | // `bpaf`'s default logic is similar but doesn't have the fallback. 85 | let progname = std::env::args_os().next().map(PathBuf::from); 86 | let progname = progname 87 | .as_deref() 88 | .and_then(Path::file_name) 89 | .and_then(OsStr::to_str) 90 | .unwrap_or(env!("CARGO_PKG_NAME")); 91 | 92 | let args = match args() 93 | .fallback_to_usage() 94 | .run_inner(bpaf::Args::current_args().set_name(progname)) 95 | { 96 | Ok(a) => a, 97 | Err(e) => { 98 | e.print_message(100); 99 | std::process::exit(e.exit_code()) 100 | } 101 | }; 102 | tracing::trace!("Parsed command-line arguments: {args:#?}"); 103 | 104 | match args.run() { 105 | Err(e) => { 106 | error!(err = %e.chain(), "exiting due to error"); 107 | ::std::process::exit(1); 108 | } 109 | Ok(rv) => { 110 | debug!("exiting with status {}", rv); 111 | std::process::exit(rv) 112 | } 113 | } 114 | } 115 | 116 | #[cfg(test)] 117 | mod tests { 118 | #[test] 119 | fn bpaf_invariants() { 120 | super::args().check_invariants(false); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /server/src/testdata/clip.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottlamb/moonfire-nvr/104ffdc2dc55bd5115c35328725e9f73efbb9b43/server/src/testdata/clip.mp4 -------------------------------------------------------------------------------- /server/src/web/accept.rs: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. 4 | 5 | //! Unified connection handling for TCP and Unix sockets. 6 | 7 | use std::pin::Pin; 8 | 9 | pub enum Listener { 10 | Tcp(tokio::net::TcpListener), 11 | Unix(tokio::net::UnixListener), 12 | } 13 | 14 | impl Listener { 15 | pub async fn accept(&mut self) -> std::io::Result { 16 | match self { 17 | Listener::Tcp(l) => { 18 | let (s, a) = l.accept().await?; 19 | s.set_nodelay(true)?; 20 | Ok(Conn { 21 | stream: Stream::Tcp(s), 22 | data: ConnData { 23 | client_unix_uid: None, 24 | client_addr: Some(a), 25 | }, 26 | }) 27 | } 28 | Listener::Unix(l) => { 29 | let (s, _a) = l.accept().await?; 30 | let ucred = s.peer_cred()?; 31 | Ok(Conn { 32 | stream: Stream::Unix(s), 33 | data: ConnData { 34 | client_unix_uid: Some(nix::unistd::Uid::from_raw(ucred.uid())), 35 | client_addr: None, 36 | }, 37 | }) 38 | } 39 | } 40 | } 41 | } 42 | 43 | /// An open connection. 44 | pub struct Conn { 45 | stream: Stream, 46 | data: ConnData, 47 | } 48 | 49 | /// Extra data associated with a connection. 50 | #[derive(Copy, Clone)] 51 | pub struct ConnData { 52 | pub client_unix_uid: Option, 53 | pub client_addr: Option, 54 | } 55 | 56 | impl Conn { 57 | pub fn data(&self) -> &ConnData { 58 | &self.data 59 | } 60 | } 61 | 62 | impl tokio::io::AsyncRead for Conn { 63 | fn poll_read( 64 | mut self: std::pin::Pin<&mut Self>, 65 | cx: &mut std::task::Context<'_>, 66 | buf: &mut tokio::io::ReadBuf<'_>, 67 | ) -> std::task::Poll> { 68 | match self.stream { 69 | Stream::Tcp(ref mut s) => Pin::new(s).poll_read(cx, buf), 70 | Stream::Unix(ref mut s) => Pin::new(s).poll_read(cx, buf), 71 | } 72 | } 73 | } 74 | 75 | impl tokio::io::AsyncWrite for Conn { 76 | fn poll_write( 77 | mut self: Pin<&mut Self>, 78 | cx: &mut std::task::Context<'_>, 79 | buf: &[u8], 80 | ) -> std::task::Poll> { 81 | match self.stream { 82 | Stream::Tcp(ref mut s) => Pin::new(s).poll_write(cx, buf), 83 | Stream::Unix(ref mut s) => Pin::new(s).poll_write(cx, buf), 84 | } 85 | } 86 | 87 | fn poll_flush( 88 | mut self: Pin<&mut Self>, 89 | cx: &mut std::task::Context<'_>, 90 | ) -> std::task::Poll> { 91 | match self.stream { 92 | Stream::Tcp(ref mut s) => Pin::new(s).poll_flush(cx), 93 | Stream::Unix(ref mut s) => Pin::new(s).poll_flush(cx), 94 | } 95 | } 96 | 97 | fn poll_shutdown( 98 | mut self: Pin<&mut Self>, 99 | cx: &mut std::task::Context<'_>, 100 | ) -> std::task::Poll> { 101 | match self.stream { 102 | Stream::Tcp(ref mut s) => Pin::new(s).poll_shutdown(cx), 103 | Stream::Unix(ref mut s) => Pin::new(s).poll_shutdown(cx), 104 | } 105 | } 106 | } 107 | 108 | /// An open stream. 109 | /// 110 | /// Ultimately `Tcp` and `Unix` result in the same syscalls, but using an 111 | /// `enum` seems easier for the moment than fighting the tokio API. 112 | enum Stream { 113 | Tcp(tokio::net::TcpStream), 114 | Unix(tokio::net::UnixStream), 115 | } 116 | -------------------------------------------------------------------------------- /server/src/web/signals.rs: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. 4 | 5 | //! `/api/signals` handling. 6 | 7 | use base::{bail, clock::Clocks, err}; 8 | use db::recording; 9 | use http::{Method, Request, StatusCode}; 10 | use url::form_urlencoded; 11 | 12 | use crate::json; 13 | 14 | use super::{ 15 | into_json_body, parse_json_body, plain_response, require_csrf_if_session, serve_json, Caller, 16 | ResponseResult, Service, 17 | }; 18 | 19 | use std::borrow::Borrow; 20 | 21 | impl Service { 22 | pub(super) async fn signals( 23 | &self, 24 | req: Request, 25 | caller: Caller, 26 | ) -> ResponseResult { 27 | match *req.method() { 28 | Method::POST => self.post_signals(req, caller).await, 29 | Method::GET | Method::HEAD => self.get_signals(&req), 30 | _ => Ok(plain_response( 31 | StatusCode::METHOD_NOT_ALLOWED, 32 | "POST, GET, or HEAD expected", 33 | )), 34 | } 35 | } 36 | 37 | async fn post_signals( 38 | &self, 39 | req: Request, 40 | caller: Caller, 41 | ) -> ResponseResult { 42 | if !caller.permissions.update_signals { 43 | bail!(PermissionDenied, msg("update_signals required")); 44 | } 45 | let (parts, b) = into_json_body(req).await?; 46 | let r: json::PostSignalsRequest = parse_json_body(&b)?; 47 | require_csrf_if_session(&caller, r.csrf)?; 48 | let now = recording::Time::from(self.db.clocks().realtime()); 49 | let mut l = self.db.lock(); 50 | let start = match r.start { 51 | json::PostSignalsTimeBase::Epoch(t) => t, 52 | json::PostSignalsTimeBase::Now(d) => now + d, 53 | }; 54 | let end = match r.end { 55 | json::PostSignalsTimeBase::Epoch(t) => t, 56 | json::PostSignalsTimeBase::Now(d) => now + d, 57 | }; 58 | l.update_signals(start..end, &r.signal_ids, &r.states)?; 59 | serve_json(&parts, &json::PostSignalsResponse { time_90k: now }) 60 | } 61 | 62 | fn get_signals(&self, req: &Request) -> ResponseResult { 63 | let mut time = recording::Time::MIN..recording::Time::MAX; 64 | if let Some(q) = req.uri().query() { 65 | for (key, value) in form_urlencoded::parse(q.as_bytes()) { 66 | let (key, value) = (key.borrow(), value.borrow()); 67 | match key { 68 | "startTime90k" => { 69 | time.start = recording::Time::parse(value) 70 | .map_err(|_| err!(InvalidArgument, msg("unparseable startTime90k")))? 71 | } 72 | "endTime90k" => { 73 | time.end = recording::Time::parse(value) 74 | .map_err(|_| err!(InvalidArgument, msg("unparseable endTime90k")))? 75 | } 76 | _ => {} 77 | } 78 | } 79 | } 80 | 81 | let mut signals = json::Signals::default(); 82 | self.db 83 | .lock() 84 | .list_changes_by_time(time, &mut |c: &db::signal::ListStateChangesRow| { 85 | signals.times_90k.push(c.when); 86 | signals.signal_ids.push(c.signal); 87 | signals.states.push(c.state); 88 | }); 89 | serve_json(req, &signals) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /server/src/web/websocket.rs: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. 4 | 5 | //! Common code for WebSockets, including the live view WebSocket and a future 6 | //! WebSocket for watching database changes. 7 | 8 | use std::pin::Pin; 9 | 10 | use crate::body::Body; 11 | use base::{bail, err}; 12 | use futures::{Future, SinkExt}; 13 | use http::{header, Request, Response}; 14 | use tokio_tungstenite::tungstenite; 15 | use tracing::Instrument; 16 | 17 | pub type WebSocketStream = 18 | tokio_tungstenite::WebSocketStream>; 19 | 20 | /// Upgrades to WebSocket and runs the supplied stream handler in a separate tokio task. 21 | /// 22 | /// Fails on `Origin` mismatch with an HTTP-level error. If the handler returns 23 | /// an error, tries to send it to the client before dropping the stream. 24 | pub(super) fn upgrade( 25 | req: Request<::hyper::body::Incoming>, 26 | handler: H, 27 | ) -> Result, base::Error> 28 | where 29 | for<'a> H: FnOnce( 30 | &'a mut WebSocketStream, 31 | ) -> Pin> + Send + 'a>> 32 | + Send 33 | + 'static, 34 | { 35 | // An `Origin` mismatch should be a HTTP-level error; this is likely a cross-site attack, 36 | // and using HTTP-level errors avoids giving any information to the Javascript running in 37 | // the browser. 38 | check_origin(req.headers())?; 39 | 40 | // Otherwise, upgrade and handle the rest in a separate task. 41 | let response = tungstenite::handshake::server::create_response_with_body(&req, Body::empty) 42 | .map_err(|e| err!(InvalidArgument, source(e)))?; 43 | let span = tracing::info_span!("websocket"); 44 | tokio::spawn( 45 | async move { 46 | let upgraded = match hyper::upgrade::on(req).await { 47 | Ok(u) => u, 48 | Err(err) => { 49 | tracing::error!(%err, "upgrade failed"); 50 | return; 51 | } 52 | }; 53 | let upgraded = hyper_util::rt::TokioIo::new(upgraded); 54 | 55 | let mut ws = WebSocketStream::from_raw_socket( 56 | upgraded, 57 | tungstenite::protocol::Role::Server, 58 | None, 59 | ) 60 | .await; 61 | if let Err(err) = handler(&mut ws).await { 62 | // TODO: use a nice JSON message format for errors. 63 | tracing::error!(err = %err.chain(), "closing with error"); 64 | let _ = ws 65 | .send(tungstenite::Message::Text(err.to_string().into())) 66 | .await; 67 | } else { 68 | tracing::info!("closing"); 69 | }; 70 | let _ = ws.close(None).await; 71 | } 72 | .instrument(span), 73 | ); 74 | Ok(response) 75 | } 76 | 77 | /// Checks the `Host` and `Origin` headers match, if the latter is supplied. 78 | /// 79 | /// Web browsers must supply origin, according to [RFC 6455 section 80 | /// 4.1](https://datatracker.ietf.org/doc/html/rfc6455#section-4.1). 81 | /// It's not required for non-browser HTTP clients. 82 | /// 83 | /// If present, verify it. Chrome doesn't honor the `s=` cookie's 84 | /// `SameSite=Lax` setting for WebSocket requests, so this is the sole 85 | /// protection against [CSWSH](https://christian-schneider.net/CrossSiteWebSocketHijacking.html). 86 | fn check_origin(headers: &header::HeaderMap) -> Result<(), base::Error> { 87 | let origin_hdr = match headers.get(http::header::ORIGIN) { 88 | None => return Ok(()), 89 | Some(o) => o, 90 | }; 91 | let Some(host_hdr) = headers.get(header::HOST) else { 92 | bail!(InvalidArgument, msg("missing Host header")); 93 | }; 94 | let host_str = host_hdr 95 | .to_str() 96 | .map_err(|_| err!(InvalidArgument, msg("bad Host header")))?; 97 | 98 | // Currently this ignores the port number. This is easiest and I think matches the browser's 99 | // rules for when it sends a cookie, so it probably doesn't cause great security problems. 100 | let host = match host_str.split_once(':') { 101 | Some((host, _port)) => host, 102 | None => host_str, 103 | }; 104 | let origin_url = origin_hdr 105 | .to_str() 106 | .ok() 107 | .and_then(|o| url::Url::parse(o).ok()) 108 | .ok_or_else(|| err!(InvalidArgument, msg("bad Origin header")))?; 109 | let origin_host = origin_url 110 | .host_str() 111 | .ok_or_else(|| err!(InvalidArgument, msg("bad Origin header")))?; 112 | if host != origin_host { 113 | bail!( 114 | PermissionDenied, 115 | msg( 116 | "cross-origin request forbidden (request host {host_hdr:?}, origin {origin_hdr:?})" 117 | ), 118 | ); 119 | } 120 | Ok(()) 121 | } 122 | 123 | #[cfg(test)] 124 | mod tests { 125 | use std::convert::TryInto; 126 | 127 | use super::*; 128 | 129 | #[test] 130 | fn origin_port_8080_okay() { 131 | // By default, Moonfire binds to port 8080. Make sure that specifying a port number works. 132 | let mut hdrs = header::HeaderMap::new(); 133 | hdrs.insert(header::HOST, "nvr:8080".try_into().unwrap()); 134 | hdrs.insert(header::ORIGIN, "http://nvr:8080/".try_into().unwrap()); 135 | assert!(check_origin(&hdrs).is_ok()); 136 | } 137 | 138 | #[test] 139 | fn origin_missing_okay() { 140 | let mut hdrs = header::HeaderMap::new(); 141 | hdrs.insert(header::HOST, "nvr".try_into().unwrap()); 142 | assert!(check_origin(&hdrs).is_ok()); 143 | } 144 | 145 | #[test] 146 | fn origin_mismatch_fails() { 147 | let mut hdrs = header::HeaderMap::new(); 148 | hdrs.insert(header::HOST, "nvr".try_into().unwrap()); 149 | hdrs.insert(header::ORIGIN, "http://evil/".try_into().unwrap()); 150 | assert!(check_origin(&hdrs).is_err()); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | /.idea 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /dist 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | .eslintcache 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | -------------------------------------------------------------------------------- /ui/.prettierignore: -------------------------------------------------------------------------------- 1 | 2 | #------------------------------------------------------------------------------------------------------------------- 3 | # Keep this section in sync with .gitignore 4 | #------------------------------------------------------------------------------------------------------------------- 5 | 6 | # dependencies 7 | /node_modules 8 | /.pnp 9 | .pnp.js 10 | /.idea 11 | 12 | # testing 13 | /coverage 14 | 15 | # production 16 | /dist 17 | 18 | # misc 19 | .DS_Store 20 | .env.local 21 | .env.development.local 22 | .env.test.local 23 | .env.production.local 24 | .eslintcache 25 | 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | #------------------------------------------------------------------------------------------------------------------- 31 | # Prettier-specific overrides 32 | #------------------------------------------------------------------------------------------------------------------- 33 | 34 | pnpm-lock.yaml 35 | -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | # Moonfire UI 2 | 3 | This is a UI built in [TypeScript](https://www.typescriptlang.org/), [React](https://react.dev/), and [MUI](https://mui.com/). 4 | 5 | See [guide/developing-ui.md](../guide/developing-ui.md) for help getting started developing it. 6 | -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 16 | 22 | 28 | 29 | 34 | 35 | 39 | 40 | Moonfire NVR 41 | 42 | 43 | 44 |
45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "dependencies": { 7 | "@emotion/react": "^11.11.4", 8 | "@emotion/styled": "^11.11.5", 9 | "@fontsource/roboto": "^4.5.8", 10 | "@mui/icons-material": "^5.15.15", 11 | "@mui/lab": "5.0.0-alpha.170", 12 | "@mui/material": "^5.15.15", 13 | "@mui/x-date-pickers": "^6.19.8", 14 | "@react-hook/resize-observer": "^1.2.6", 15 | "date-fns": "^2.30.0", 16 | "date-fns-tz": "^2.0.1", 17 | "react": "^18.2.0", 18 | "react-dom": "^18.2.0", 19 | "react-hook-form": "^7.51.2", 20 | "react-hook-form-mui": "^6.8.0", 21 | "react-router-dom": "^6.22.3" 22 | }, 23 | "scripts": { 24 | "check-format": "prettier --check --ignore-path .prettierignore .", 25 | "dev": "vite", 26 | "build": "tsc && vite build", 27 | "format": "prettier --write --ignore-path .prettierignore .", 28 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 29 | "preview": "vite preview", 30 | "test": "vitest" 31 | }, 32 | "eslintConfig": { 33 | "extends": [ 34 | "eslint:recommended", 35 | "plugin:vitest/recommended", 36 | "plugin:react/recommended", 37 | "plugin:react/jsx-runtime", 38 | "plugin:react-hooks/recommended" 39 | ], 40 | "overrides": [ 41 | { 42 | "files": [ 43 | "*.ts", 44 | "*.tsx" 45 | ], 46 | "rules": { 47 | "no-undef": "off" 48 | } 49 | } 50 | ], 51 | "parser": "@typescript-eslint/parser", 52 | "parserOptions": { 53 | "ecmaVersion": "latest", 54 | "sourceType": "module" 55 | }, 56 | "rules": { 57 | "no-restricted-imports": [ 58 | "error", 59 | { 60 | "name": "@mui/material", 61 | "message": "Please use the 'import Button from \"material-ui/core/Button\";' style instead; see https://material-ui.com/guides/minimizing-bundle-size/#option-1" 62 | }, 63 | { 64 | "name": "@mui/icons-material", 65 | "message": "Please use the 'import MenuIcon from \"material-ui/icons/Menu\";' style instead; see https://material-ui.com/guides/minimizing-bundle-size/#option-1" 66 | } 67 | ], 68 | "no-unused-vars": [ 69 | "error", 70 | { 71 | "args": "none" 72 | } 73 | ], 74 | "react/no-unescaped-entities": "off" 75 | }, 76 | "settings": { 77 | "react": { 78 | "version": "detect" 79 | } 80 | } 81 | }, 82 | "devDependencies": { 83 | "@babel/core": "^7.24.4", 84 | "@babel/preset-env": "^7.24.4", 85 | "@babel/preset-react": "^7.24.1", 86 | "@babel/preset-typescript": "^7.24.1", 87 | "@swc/core": "^1.4.12", 88 | "@testing-library/dom": "^8.20.1", 89 | "@testing-library/jest-dom": "^6.4.2", 90 | "@testing-library/react": "^13.4.0", 91 | "@testing-library/user-event": "^14.5.2", 92 | "@types/node": "^18.19.29", 93 | "@types/react": "^18.2.74", 94 | "@types/react-dom": "^18.2.24", 95 | "@typescript-eslint/eslint-plugin": "^6.21.0", 96 | "@typescript-eslint/parser": "^6.21.0", 97 | "@vitejs/plugin-legacy": "^5.3.2", 98 | "@vitejs/plugin-react-swc": "^3.6.0", 99 | "eslint": "^8.57.0", 100 | "eslint-plugin-react": "^7.34.1", 101 | "eslint-plugin-react-hooks": "^4.6.0", 102 | "eslint-plugin-react-refresh": "^0.4.6", 103 | "eslint-plugin-vitest": "^0.3.26", 104 | "http-proxy-middleware": "^2.0.6", 105 | "jsdom": "^24.0.0", 106 | "msw": "^2.2.13", 107 | "prettier": "^2.8.8", 108 | "terser": "^5.30.3", 109 | "ts-node": "^10.9.2", 110 | "typescript": "^5.4.4", 111 | "vite": "^5.2.8", 112 | "vite-plugin-compression": "^0.5.1", 113 | "vitest": "^1.4.0" 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /ui/public/favicons/android-chrome-192x192-22fa756c4c8a94dde.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottlamb/moonfire-nvr/104ffdc2dc55bd5115c35328725e9f73efbb9b43/ui/public/favicons/android-chrome-192x192-22fa756c4c8a94dde.png -------------------------------------------------------------------------------- /ui/public/favicons/android-chrome-512x512-0403b1c77057918bb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottlamb/moonfire-nvr/104ffdc2dc55bd5115c35328725e9f73efbb9b43/ui/public/favicons/android-chrome-512x512-0403b1c77057918bb.png -------------------------------------------------------------------------------- /ui/public/favicons/apple-touch-icon-94a09b5d2ddb5af47.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottlamb/moonfire-nvr/104ffdc2dc55bd5115c35328725e9f73efbb9b43/ui/public/favicons/apple-touch-icon-94a09b5d2ddb5af47.png -------------------------------------------------------------------------------- /ui/public/favicons/favicon-16x16-b16b3f2883aacf9f1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottlamb/moonfire-nvr/104ffdc2dc55bd5115c35328725e9f73efbb9b43/ui/public/favicons/favicon-16x16-b16b3f2883aacf9f1.png -------------------------------------------------------------------------------- /ui/public/favicons/favicon-32x32-ab95901a9e0d040e2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottlamb/moonfire-nvr/104ffdc2dc55bd5115c35328725e9f73efbb9b43/ui/public/favicons/favicon-32x32-ab95901a9e0d040e2.png -------------------------------------------------------------------------------- /ui/public/favicons/favicon-e6c276d91e88aab6f.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottlamb/moonfire-nvr/104ffdc2dc55bd5115c35328725e9f73efbb9b43/ui/public/favicons/favicon-e6c276d91e88aab6f.ico -------------------------------------------------------------------------------- /ui/public/favicons/safari-pinned-tab-9792c2c82f04639f8.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: / 4 | -------------------------------------------------------------------------------- /ui/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Moonfire NVR", 3 | "short_name": "Moonfire", 4 | "description": "security camera network video recorder", 5 | "icons": [ 6 | { 7 | "src": "/favicons/android-chrome-192x192-22fa756c4c8a94dde.png", 8 | "sizes": "192x192", 9 | "type": "image/png" 10 | }, 11 | { 12 | "src": "/favicons/android-chrome-512x512-0403b1c77057918bb.png", 13 | "sizes": "512x512", 14 | "type": "image/png" 15 | } 16 | ], 17 | "theme_color": "#e04e1b", 18 | "background_color": "#ffffff", 19 | "display": "standalone", 20 | "start_url": "." 21 | } 22 | -------------------------------------------------------------------------------- /ui/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | import { screen } from "@testing-library/react"; 6 | import App from "./App"; 7 | import { renderWithCtx } from "./testutil"; 8 | import { http, HttpResponse } from "msw"; 9 | import { setupServer } from "msw/node"; 10 | import { beforeAll, afterAll, afterEach, expect, test } from "vitest"; 11 | 12 | const server = setupServer( 13 | http.get("/api/", () => { 14 | return HttpResponse.text("server error", { status: 503 }); 15 | }) 16 | ); 17 | beforeAll(() => server.listen({ onUnhandledRequest: "error" })); 18 | afterEach(() => server.resetHandlers()); 19 | afterAll(() => server.close()); 20 | 21 | test("instantiate", async () => { 22 | renderWithCtx(); 23 | expect(screen.getByText(/Moonfire NVR/)).toBeInTheDocument(); 24 | }); 25 | -------------------------------------------------------------------------------- /ui/src/App.tsx: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | /** 6 | * @fileoverview Main application 7 | * 8 | * This defines `` to lay out the visual structure of the application: 9 | * 10 | * - top menu bar with fixed components and a spot for activities to add 11 | * their own elements 12 | * - navigation drawer 13 | * - main activity error 14 | * 15 | * It handles the login state and, once logged in, delegates to the appropriate 16 | * activity based on the URL. Each activity is expected to return the supplied 17 | * `` with its own `children` and optionally `activityMenuPart` filled 18 | * in. 19 | */ 20 | 21 | import Container from "@mui/material/Container"; 22 | import React, { useEffect, useState } from "react"; 23 | import * as api from "./api"; 24 | import Login from "./Login"; 25 | import { useSnackbars } from "./snackbars"; 26 | import ListActivity from "./List"; 27 | import { Routes, Route, Navigate } from "react-router-dom"; 28 | import LiveActivity from "./Live"; 29 | import UsersActivity from "./Users"; 30 | import ChangePassword from "./ChangePassword"; 31 | import Header from "./components/Header"; 32 | 33 | export type LoginState = 34 | | "unknown" 35 | | "logged-in" 36 | | "not-logged-in" 37 | | "server-requires-login" 38 | | "user-requested-login"; 39 | 40 | export interface FrameProps { 41 | activityMenuPart?: JSX.Element; 42 | children?: React.ReactNode; 43 | } 44 | 45 | function App() { 46 | const [toplevel, setToplevel] = useState(null); 47 | const [timeZoneName, setTimeZoneName] = useState(null); 48 | const [fetchSeq, setFetchSeq] = useState(0); 49 | const [loginState, setLoginState] = useState("unknown"); 50 | const [changePasswordOpen, setChangePasswordOpen] = useState(false); 51 | const [error, setError] = useState(null); 52 | const needNewFetch = () => setFetchSeq((seq) => seq + 1); 53 | const snackbars = useSnackbars(); 54 | 55 | const onLoginSuccess = () => { 56 | setLoginState("logged-in"); 57 | needNewFetch(); 58 | }; 59 | 60 | const logout = async () => { 61 | const resp = await api.logout( 62 | { 63 | csrf: toplevel!.user!.session!.csrf, 64 | }, 65 | {} 66 | ); 67 | switch (resp.status) { 68 | case "aborted": 69 | break; 70 | case "error": 71 | snackbars.enqueue({ 72 | message: "Logout failed: " + resp.message, 73 | }); 74 | break; 75 | case "success": 76 | needNewFetch(); 77 | break; 78 | } 79 | }; 80 | 81 | useEffect(() => { 82 | const abort = new AbortController(); 83 | const doFetch = async (signal: AbortSignal) => { 84 | const resp = await api.toplevel({ signal }); 85 | switch (resp.status) { 86 | case "aborted": 87 | break; 88 | case "error": 89 | if (resp.httpStatus === 401) { 90 | setLoginState("server-requires-login"); 91 | return; 92 | } 93 | setError(resp); 94 | break; 95 | case "success": 96 | setError(null); 97 | setLoginState( 98 | resp.response.user?.session === undefined 99 | ? "not-logged-in" 100 | : "logged-in" 101 | ); 102 | setToplevel(resp.response); 103 | setTimeZoneName(resp.response.timeZoneName); 104 | } 105 | }; 106 | doFetch(abort.signal); 107 | return () => { 108 | abort.abort(); 109 | }; 110 | }, [fetchSeq]); 111 | 112 | const Frame = ({ activityMenuPart, children }: FrameProps): JSX.Element => { 113 | return ( 114 | <> 115 |
123 | { 130 | setLoginState((s) => 131 | s === "user-requested-login" ? "not-logged-in" : s 132 | ); 133 | }} 134 | /> 135 | {toplevel?.user !== undefined && ( 136 | setChangePasswordOpen(false)} 140 | /> 141 | )} 142 | {error !== null && ( 143 | 144 |

Error querying server

145 |
{error.message}
146 |

147 | You may find more information in the Javascript console. Try 148 | reloading the page once you believe the problem is resolved. 149 |

150 |
151 | )} 152 | {children} 153 | 154 | ); 155 | }; 156 | 157 | if (toplevel == null) { 158 | return ; 159 | } 160 | return ( 161 | 162 | 170 | } 171 | /> 172 | } 175 | /> 176 | 180 | } 181 | /> 182 | } /> 183 | 184 | ); 185 | } 186 | 187 | export default App; 188 | -------------------------------------------------------------------------------- /ui/src/AppMenu.tsx: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | import Button from "@mui/material/Button"; 6 | import IconButton from "@mui/material/IconButton"; 7 | import Menu from "@mui/material/Menu"; 8 | import MenuItem from "@mui/material/MenuItem"; 9 | import { useTheme } from "@mui/material/styles"; 10 | import Toolbar from "@mui/material/Toolbar"; 11 | import Typography from "@mui/material/Typography"; 12 | import AccountCircle from "@mui/icons-material/AccountCircle"; 13 | import MenuIcon from "@mui/icons-material/Menu"; 14 | import React from "react"; 15 | import { LoginState } from "./App"; 16 | import Box from "@mui/material/Box"; 17 | import { CurrentMode, useThemeMode } from "./components/ThemeMode"; 18 | import Brightness2 from "@mui/icons-material/Brightness2"; 19 | import Brightness7 from "@mui/icons-material/Brightness7"; 20 | import BrightnessAuto from "@mui/icons-material/BrightnessAuto"; 21 | import Tooltip from "@mui/material/Tooltip"; 22 | 23 | interface Props { 24 | loginState: LoginState; 25 | requestLogin: () => void; 26 | logout: () => void; 27 | changePassword: () => void; 28 | menuClick?: () => void; 29 | activityMenuPart?: JSX.Element; 30 | } 31 | 32 | // https://material-ui.com/components/app-bar/ 33 | function MoonfireMenu(props: Props) { 34 | const { choosenTheme, changeTheme } = useThemeMode(); 35 | const theme = useTheme(); 36 | const [accountMenuAnchor, setAccountMenuAnchor] = 37 | React.useState(null); 38 | 39 | const handleMenu = (event: React.MouseEvent) => { 40 | setAccountMenuAnchor(event.currentTarget); 41 | }; 42 | 43 | const handleClose = () => { 44 | setAccountMenuAnchor(null); 45 | }; 46 | 47 | const handleLogout = () => { 48 | // Note this close should happen before `auth` toggles, or material-ui will 49 | // be unhappy about the anchor element not being part of the layout. 50 | handleClose(); 51 | props.logout(); 52 | }; 53 | 54 | const handleChangePassword = () => { 55 | handleClose(); 56 | props.changePassword(); 57 | }; 58 | 59 | return ( 60 | <> 61 | 62 | 68 | 69 | 70 | 71 | Moonfire NVR 72 | 73 | {props.activityMenuPart !== null && ( 74 | 75 | {props.activityMenuPart} 76 | 77 | )} 78 | 79 | 80 | {choosenTheme === CurrentMode.Light ? ( 81 | 82 | ) : choosenTheme === CurrentMode.Dark ? ( 83 | 84 | ) : ( 85 | 86 | )} 87 | 88 | 89 | {props.loginState !== "unknown" && props.loginState !== "logged-in" && ( 90 | 93 | )} 94 | {props.loginState === "logged-in" && ( 95 |
96 | 104 | 105 | 106 | 120 | 121 | Change password 122 | 123 | Logout 124 | 125 |
126 | )} 127 |
128 | 129 | ); 130 | } 131 | 132 | export default MoonfireMenu; 133 | -------------------------------------------------------------------------------- /ui/src/ChangePassword.tsx: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2022 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | import { useForm } from "react-hook-form"; 6 | import { FormContainer, PasswordElement } from "react-hook-form-mui"; 7 | import Button from "@mui/material/Button"; 8 | import LoadingButton from "@mui/lab/LoadingButton"; 9 | import Dialog from "@mui/material/Dialog"; 10 | import DialogActions from "@mui/material/DialogActions"; 11 | import DialogContent from "@mui/material/DialogContent"; 12 | import DialogTitle from "@mui/material/DialogTitle"; 13 | import TextField from "@mui/material/TextField"; 14 | import React from "react"; 15 | import * as api from "./api"; 16 | import { useSnackbars } from "./snackbars"; 17 | import NewPassword from "./NewPassword"; 18 | 19 | interface Props { 20 | user: api.ToplevelUser; 21 | open: boolean; 22 | handleClose: () => void; 23 | } 24 | 25 | interface Request { 26 | userId: number; 27 | csrf: string; 28 | currentPassword: string; 29 | newPassword: string; 30 | } 31 | 32 | interface FormData { 33 | currentPassword: string; 34 | newPassword: string; 35 | } 36 | 37 | /** 38 | * Dialog for changing password. 39 | * 40 | * There's probably a good set of best practices and even libraries for form 41 | * validation. I don't know them, but I played with a few similar forms, and 42 | * this code tries to behave similarly: 43 | * 44 | * - current password if the server has said the value is wrong and the form 45 | * value hasn't changed. 46 | * - new password on blurring the field or submit attempt if it doesn't meet 47 | * validation rules (as opposed to showing errors while you're typing), 48 | * cleared as soon as validation succeeds. 49 | * - confirm password when new password changes away (unless confirm is empty), 50 | * on blur, or on submit, cleared any time validation succeeds. 51 | * 52 | * The submit button is greyed on new/confirm password error. So it's initially 53 | * clickable (to give you the idea of what to do) but will complain more visibly 54 | * if you don't fill fields correctly first. 55 | */ 56 | const ChangePassword = ({ user, open, handleClose }: Props) => { 57 | const snackbars = useSnackbars(); 58 | const formContext = useForm(); 59 | const setError = formContext.setError; 60 | const [loading, setLoading] = React.useState(null); 61 | React.useEffect(() => { 62 | if (loading === null) { 63 | return; 64 | } 65 | const abort = new AbortController(); 66 | const send = async (signal: AbortSignal) => { 67 | const response = await api.updateUser( 68 | loading.userId, 69 | { 70 | csrf: loading.csrf, 71 | precondition: { 72 | password: loading.currentPassword, 73 | }, 74 | update: { 75 | password: loading.newPassword, 76 | }, 77 | }, 78 | { signal } 79 | ); 80 | switch (response.status) { 81 | case "aborted": 82 | break; 83 | case "error": 84 | if (response.httpStatus === 412) { 85 | setError("currentPassword", { 86 | message: "Incorrect password.", 87 | }); 88 | } else { 89 | snackbars.enqueue({ 90 | message: response.message, 91 | key: "login-error", 92 | }); 93 | } 94 | setLoading(null); 95 | break; 96 | case "success": 97 | setLoading(null); 98 | snackbars.enqueue({ 99 | message: "Password changed successfully", 100 | key: "password-changed", 101 | }); 102 | handleClose(); 103 | } 104 | }; 105 | send(abort.signal); 106 | return () => { 107 | abort.abort(); 108 | }; 109 | }, [loading, handleClose, snackbars, setError]); 110 | const onSuccess = (data: FormData) => { 111 | // Suppress concurrent attempts. 112 | console.log("onSuccess", data); 113 | if (loading !== null) { 114 | return; 115 | } 116 | setLoading({ 117 | userId: user.id, 118 | csrf: user.session!.csrf, 119 | currentPassword: data.currentPassword, 120 | newPassword: data.newPassword, 121 | }); 122 | }; 123 | 124 | return ( 125 | 131 | Change password 132 | 133 | 134 | 135 | {/* The username is here in the hopes it will help password managers 136 | * find the correct entry. It's otherwise unused. */} 137 | 148 | 158 | 159 | 160 | 161 | 162 | 165 | 171 | Change 172 | 173 | 174 | 175 | 176 | ); 177 | }; 178 | 179 | export default ChangePassword; 180 | -------------------------------------------------------------------------------- /ui/src/ErrorBoundary.test.tsx: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | import { render, screen } from "@testing-library/react"; 6 | import ErrorBoundary from "./ErrorBoundary"; 7 | import { expect, test } from "vitest"; 8 | 9 | const ThrowsLiteralComponent = () => { 10 | throw "simple string error"; 11 | }; 12 | 13 | test("renders string error", () => { 14 | render( 15 | 16 | 17 | 18 | ); 19 | const msgElement = screen.getByText(/simple string error/); 20 | expect(msgElement).toBeInTheDocument(); 21 | }); 22 | 23 | test("renders child on success", () => { 24 | render(foo); 25 | const fooElement = screen.getByText(/foo/); 26 | expect(fooElement).toBeInTheDocument(); 27 | const sorryElement = screen.queryByText(/Sorry/); 28 | expect(sorryElement).toBeNull(); 29 | }); 30 | -------------------------------------------------------------------------------- /ui/src/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | import Avatar from "@mui/material/Avatar"; 6 | import Container from "@mui/material/Container"; 7 | import BugReportIcon from "@mui/icons-material/BugReport"; 8 | import React from "react"; 9 | 10 | interface State { 11 | error: any; 12 | } 13 | 14 | interface Props { 15 | children: React.ReactNode; 16 | } 17 | 18 | /** 19 | * A simple error 20 | * boundary meant to go at the top level. 21 | * 22 | * The assumption is that any error here is a bug in the UI layer. Components 23 | * shouldn't throw errors upward even if there are network or server problems. 24 | * 25 | * Limitations: as described in the React docs, error boundaries don't catch 26 | * errors in async code / rejected Promises. 27 | */ 28 | class MoonfireErrorBoundary extends React.Component { 29 | constructor(props: Props) { 30 | super(props); 31 | this.state = { error: null }; 32 | } 33 | 34 | static getDerivedStateFromError(error: any) { 35 | return { error }; 36 | } 37 | 38 | componentDidCatch(error: any, errorInfo: React.ErrorInfo) { 39 | console.error("Uncaught error:", error, errorInfo); 40 | } 41 | 42 | render() { 43 | const { children } = this.props; 44 | 45 | if (this.state.error !== null) { 46 | let error; 47 | if (this.state.error.stack !== undefined) { 48 | error =
{this.state.error.stack}
; 49 | } else if (this.state.error instanceof Error) { 50 | error = ( 51 | <> 52 |
{this.state.error.name}
53 |
{this.state.error.message}
54 | 55 | ); 56 | } else { 57 | error =
{this.state.error}
; 58 | } 59 | 60 | return ( 61 | 62 | 69 | 70 | 71 |

Error

72 | 73 |

74 | Sorry! You've found a bug in Moonfire NVR. We need a good bug report 75 | to get it fixed. Can you help? 76 |

77 | 78 |

How to report a bug

79 | 80 |

81 | Please open{" "} 82 | 83 | Moonfire NVR's issue tracker 84 | {" "} 85 | and see if this problem has already been reported. 86 |

87 | 88 |

Can't find anything?

89 | 90 |

Open a new issue with as much detail as you can:

91 | 92 |
    93 |
  • the version of Moonfire NVR you're using
  • 94 |
  • 95 | your environment, including: 96 |
      97 |
    • web browser: Chrome, Firefox, Safari, etc.
    • 98 |
    • platform: macOS, Windows, Linux, Android, iOS, etc.
    • 99 |
    • browser extensions
    • 100 |
    • anything special about your Moonfire NVR setup
    • 101 |
    102 |
  • 103 |
  • all the errors you see in your browser's Javascript console
  • 104 |
  • steps to reproduce, if possible
  • 105 |
106 | 107 |

Already reported?

108 | 109 |
    110 |
  • +1 the issue so we know more people are affected.
  • 111 |
  • add any new details you've noticed.
  • 112 |
113 | 114 |

The error

115 | 116 | {error} 117 |
118 | ); 119 | } 120 | return children; 121 | } 122 | } 123 | 124 | export default MoonfireErrorBoundary; 125 | -------------------------------------------------------------------------------- /ui/src/List/DisplaySelector.tsx: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | import Checkbox from "@mui/material/Checkbox"; 6 | import InputLabel from "@mui/material/InputLabel"; 7 | import FormControl from "@mui/material/FormControl"; 8 | import MenuItem from "@mui/material/MenuItem"; 9 | import Select from "@mui/material/Select"; 10 | import FormControlLabel from "@mui/material/FormControlLabel"; 11 | import FormGroup from "@mui/material/FormGroup"; 12 | import Paper from "@mui/material/Paper"; 13 | import { useTheme } from "@mui/material/styles"; 14 | 15 | interface Props { 16 | split90k?: number; 17 | setSplit90k: (split90k?: number) => void; 18 | trimStartAndEnd: boolean; 19 | setTrimStartAndEnd: (trimStartAndEnd: boolean) => void; 20 | timestampTrack: boolean; 21 | setTimestampTrack: (timestampTrack: boolean) => void; 22 | } 23 | 24 | const DURATIONS: [string, number | undefined][] = [ 25 | ["1 hour", 60 * 60 * 90000], 26 | ["4 hours", 4 * 60 * 60 * 90000], 27 | ["24 hours", 24 * 60 * 60 * 90000], 28 | ["infinite", undefined], 29 | ]; 30 | 31 | export const DEFAULT_DURATION = DURATIONS[0][1]; 32 | 33 | /** 34 | * Returns a card for setting options relating to how videos are displayed. 35 | */ 36 | const DisplaySelector = (props: Props) => { 37 | const theme = useTheme(); 38 | return ( 39 | 40 | 41 | 42 | 43 | Max video duration 44 | 45 | 66 | 67 | 77 | props.setTrimStartAndEnd(event.target.checked) 78 | } 79 | name="trim-start-and-end" 80 | color="secondary" 81 | /> 82 | } 83 | label="Trim start and end" 84 | /> 85 | 93 | props.setTimestampTrack(event.target.checked) 94 | } 95 | name="timestamp-track" 96 | color="secondary" 97 | /> 98 | } 99 | label="Timestamp track" 100 | /> 101 | 102 | 103 | ); 104 | }; 105 | 106 | export default DisplaySelector; 107 | -------------------------------------------------------------------------------- /ui/src/List/StreamMultiSelector.tsx: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | import Box from "@mui/material/Box"; 6 | import { Camera, Stream, StreamType } from "../types"; 7 | import Checkbox from "@mui/material/Checkbox"; 8 | import { ToplevelResponse } from "../api"; 9 | import Paper from "@mui/material/Paper"; 10 | import { useTheme } from "@mui/material/styles"; 11 | 12 | interface Props { 13 | toplevel: ToplevelResponse; 14 | selected: Set; 15 | setSelected: (selected: Set) => void; 16 | } 17 | 18 | /** Returns a table which allows selecting zero or more streams. */ 19 | const StreamMultiSelector = ({ toplevel, selected, setSelected }: Props) => { 20 | const theme = useTheme(); 21 | const setStream = (s: Stream, checked: boolean) => { 22 | const updated = new Set(selected); 23 | if (checked) { 24 | updated.add(s.id); 25 | } else { 26 | updated.delete(s.id); 27 | } 28 | setSelected(updated); 29 | }; 30 | const toggleType = (st: StreamType) => { 31 | let updated = new Set(selected); 32 | let foundAny = false; 33 | for (const sid of selected) { 34 | const s = toplevel.streams.get(sid); 35 | if (s === undefined) { 36 | continue; 37 | } 38 | if (s.streamType === st) { 39 | updated.delete(s.id); 40 | foundAny = true; 41 | } 42 | } 43 | if (!foundAny) { 44 | for (const c of toplevel.cameras) { 45 | if (c.streams[st] !== undefined) { 46 | updated.add(c.streams[st as StreamType]!.id); 47 | } 48 | } 49 | } 50 | setSelected(updated); 51 | }; 52 | const toggleCamera = (c: Camera) => { 53 | const updated = new Set(selected); 54 | let foundAny = false; 55 | for (const st in c.streams) { 56 | const s = c.streams[st as StreamType]!; 57 | if (selected.has(s.id)) { 58 | updated.delete(s.id); 59 | foundAny = true; 60 | } 61 | } 62 | if (!foundAny) { 63 | for (const st in c.streams) { 64 | updated.add(c.streams[st as StreamType]!.id); 65 | } 66 | } 67 | setSelected(updated); 68 | }; 69 | 70 | const cameraRows = toplevel.cameras.map((c) => { 71 | function checkbox(st: StreamType) { 72 | const s = c.streams[st]; 73 | if (s === undefined) { 74 | return ; 75 | } 76 | return ( 77 | setStream(s, event.target.checked)} 82 | /> 83 | ); 84 | } 85 | return ( 86 | 87 | toggleCamera(c)}>{c.shortName} 88 | {checkbox("main")} 89 | {checkbox("sub")} 90 | 91 | ); 92 | }); 93 | return ( 94 | 95 | 115 | 116 | 117 | 118 | toggleType("main")}>main 119 | toggleType("sub")}>sub 120 | 121 | 122 | {cameraRows} 123 | 124 | 125 | ); 126 | }; 127 | 128 | export default StreamMultiSelector; 129 | -------------------------------------------------------------------------------- /ui/src/Live/index.tsx: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | import Container from "@mui/material/Container"; 6 | import ErrorIcon from "@mui/icons-material/Error"; 7 | import { Camera } from "../types"; 8 | import LiveCamera, { MediaSourceApi } from "./LiveCamera"; 9 | import Multiview, { MultiviewChooser } from "./Multiview"; 10 | import { FrameProps } from "../App"; 11 | import { useSearchParams } from "react-router-dom"; 12 | import { useEffect, useState } from "react"; 13 | 14 | export interface LiveProps { 15 | cameras: Camera[]; 16 | Frame: (props: FrameProps) => JSX.Element; 17 | } 18 | 19 | const Live = ({ cameras, Frame }: LiveProps) => { 20 | const [searchParams, setSearchParams] = useSearchParams(); 21 | 22 | const [multiviewLayoutIndex, setMultiviewLayoutIndex] = useState( 23 | Number.parseInt( 24 | searchParams.get("layout") || 25 | localStorage.getItem("multiviewLayoutIndex") || 26 | "0", 27 | 10 28 | ) 29 | ); 30 | 31 | useEffect(() => { 32 | if (searchParams.has("layout")) 33 | localStorage.setItem( 34 | "multiviewLayoutIndex", 35 | searchParams.get("layout") || "0" 36 | ); 37 | }, [searchParams]); 38 | 39 | const mediaSourceApi = MediaSourceApi; 40 | if (mediaSourceApi === undefined) { 41 | return ( 42 | 43 | 44 | 51 | Live view doesn't work yet on your browser. See{" "} 52 | 53 | #121 54 | 55 | . 56 | 57 | 58 | ); 59 | } 60 | return ( 61 | { 66 | setMultiviewLayoutIndex(value); 67 | setSearchParams({ layout: value.toString() }); 68 | }} 69 | /> 70 | } 71 | > 72 | ( 76 | 81 | )} 82 | /> 83 | 84 | ); 85 | }; 86 | 87 | export default Live; 88 | -------------------------------------------------------------------------------- /ui/src/Live/parser.ts: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | export interface Part { 6 | mimeType: string; 7 | videoSampleEntryId: number; 8 | body: Uint8Array; 9 | } 10 | 11 | interface ParseSuccess { 12 | status: "success"; 13 | part: Part; 14 | } 15 | 16 | interface ParseError { 17 | status: "error"; 18 | errorMessage: string; 19 | } 20 | 21 | const DECODER = new TextDecoder("utf-8"); 22 | const CR = "\r".charCodeAt(0); 23 | const NL = "\n".charCodeAt(0); 24 | 25 | type ParseResult = ParseSuccess | ParseError; 26 | 27 | /// Parses a live stream message. 28 | export function parsePart(raw: Uint8Array): ParseResult { 29 | // Parse into headers and body. 30 | const headers = new Headers(); 31 | let pos = 0; 32 | for (;;) { 33 | const cr = raw.indexOf(CR, pos); 34 | if (cr === -1 || raw.length === cr + 1 || raw[cr + 1] !== NL) { 35 | return { 36 | status: "error", 37 | errorMessage: "header that never ends (no '\\r\\n')!", 38 | }; 39 | } 40 | const line = DECODER.decode(raw.slice(pos, cr)); 41 | pos = cr + 2; 42 | if (line.length === 0) { 43 | break; 44 | } 45 | const colon = line.indexOf(":"); 46 | if (colon === -1 || line.length === colon + 1 || line[colon + 1] !== " ") { 47 | return { 48 | status: "error", 49 | errorMessage: "invalid name/value separator (no ': ')!", 50 | }; 51 | } 52 | const name = line.substring(0, colon); 53 | const value = line.substring(colon + 2); 54 | headers.append(name, value); 55 | } 56 | const body = raw.slice(pos); 57 | 58 | const mimeType = headers.get("Content-Type"); 59 | if (mimeType === null) { 60 | return { status: "error", errorMessage: "no Content-Type" }; 61 | } 62 | const videoSampleEntryIdStr = headers.get("X-Video-Sample-Entry-Id"); 63 | if (videoSampleEntryIdStr === null) { 64 | return { status: "error", errorMessage: "no X-Video-Sample-Entry-Id" }; 65 | } 66 | const videoSampleEntryId = parseInt(videoSampleEntryIdStr, 10); 67 | if (isNaN(videoSampleEntryId)) { 68 | return { status: "error", errorMessage: "invalid X-Video-Sample-Entry-Id" }; 69 | } 70 | return { 71 | status: "success", 72 | part: { mimeType, videoSampleEntryId, body }, 73 | }; 74 | } 75 | -------------------------------------------------------------------------------- /ui/src/Login.test.tsx: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | import { 6 | screen, 7 | waitFor, 8 | waitForElementToBeRemoved, 9 | } from "@testing-library/react"; 10 | import userEvent from "@testing-library/user-event"; 11 | import { delay, http, HttpResponse } from "msw"; 12 | import { setupServer } from "msw/node"; 13 | import Login from "./Login"; 14 | import { renderWithCtx } from "./testutil"; 15 | import { 16 | beforeAll, 17 | afterEach, 18 | afterAll, 19 | test, 20 | vi, 21 | expect, 22 | beforeEach, 23 | } from "vitest"; 24 | 25 | // Set up a fake API backend. 26 | const server = setupServer( 27 | http.post>("/api/login", async ({ request }) => { 28 | const body = await request.json(); 29 | const { username, password } = body!; 30 | console.log( 31 | "/api/login post username=" + username + " password=" + password 32 | ); 33 | if (username === "slamb" && password === "hunter2") { 34 | return new HttpResponse(null, { status: 204 }); 35 | } else if (username === "delay") { 36 | await delay("infinite"); 37 | return new HttpResponse(null); 38 | } else if (username === "server-error") { 39 | return HttpResponse.text("server error", { status: 503 }); 40 | } else if (username === "network-error") { 41 | return HttpResponse.error(); 42 | } else { 43 | return HttpResponse.text("bad credentials", { status: 401 }); 44 | } 45 | }) 46 | ); 47 | beforeAll(() => server.listen({ onUnhandledRequest: "error" })); 48 | beforeEach(() => { 49 | // Using fake timers allows tests to jump forward to when a snackbar goes away, without incurring 50 | // extra real delay. msw only appears to work when `shouldAdvanceTime` is set though. 51 | vi.useFakeTimers({ 52 | shouldAdvanceTime: true, 53 | }); 54 | }); 55 | afterEach(() => { 56 | vi.runOnlyPendingTimers(); 57 | vi.useRealTimers(); 58 | server.resetHandlers(); 59 | }); 60 | afterAll(() => server.close()); 61 | 62 | test("success", async () => { 63 | const user = userEvent.setup(); 64 | const handleClose = vi.fn().mockName("handleClose"); 65 | const onSuccess = vi.fn().mockName("handleOpen"); 66 | renderWithCtx( 67 | 68 | ); 69 | await user.type(screen.getByLabelText(/Username/), "slamb"); 70 | await user.type(screen.getByLabelText(/Password/), "hunter2{enter}"); 71 | await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); 72 | }); 73 | 74 | test("close while pending", async () => { 75 | const user = userEvent.setup(); 76 | const handleClose = vi.fn().mockName("handleClose"); 77 | const onSuccess = vi.fn().mockName("handleOpen"); 78 | const { rerender } = renderWithCtx( 79 | 80 | ); 81 | await user.type(screen.getByLabelText(/Username/), "delay"); 82 | await user.type(screen.getByLabelText(/Password/), "hunter2{enter}"); 83 | expect(screen.getByRole("button", { name: /Log in/ })).toBeInTheDocument(); 84 | rerender( 85 | 86 | ); 87 | await waitFor(() => 88 | expect( 89 | screen.queryByRole("button", { name: /Log in/ }) 90 | ).not.toBeInTheDocument() 91 | ); 92 | }); 93 | 94 | test("bad credentials", async () => { 95 | const user = userEvent.setup(); 96 | const handleClose = vi.fn().mockName("handleClose"); 97 | const onSuccess = vi.fn().mockName("handleOpen"); 98 | renderWithCtx( 99 | 100 | ); 101 | await user.type(screen.getByLabelText(/Username/), "slamb"); 102 | await user.type(screen.getByLabelText(/Password/), "wrong{enter}"); 103 | await screen.findByText(/bad credentials/); 104 | expect(onSuccess).toHaveBeenCalledTimes(0); 105 | }); 106 | 107 | test("server error", async () => { 108 | const user = userEvent.setup(); 109 | const handleClose = vi.fn().mockName("handleClose"); 110 | const onSuccess = vi.fn().mockName("handleOpen"); 111 | renderWithCtx( 112 | 113 | ); 114 | await user.type(screen.getByLabelText(/Username/), "server-error"); 115 | await user.type(screen.getByLabelText(/Password/), "asdf{enter}"); 116 | await screen.findByText(/server error/); 117 | vi.runOnlyPendingTimers(); 118 | await waitForElementToBeRemoved(() => screen.queryByText(/server error/)); 119 | expect(onSuccess).toHaveBeenCalledTimes(0); 120 | }); 121 | 122 | test("network error", async () => { 123 | const user = userEvent.setup(); 124 | const handleClose = vi.fn().mockName("handleClose"); 125 | const onSuccess = vi.fn().mockName("handleOpen"); 126 | renderWithCtx( 127 | 128 | ); 129 | await user.type(screen.getByLabelText(/Username/), "network-error"); 130 | await user.type(screen.getByLabelText(/Password/), "asdf{enter}"); 131 | 132 | // This is the text chosen by msw: 133 | // https://github.com/mswjs/interceptors/blob/122a6533ce57d551dc3b59b3bb43a39026989b70/src/interceptors/fetch/index.ts#L187 134 | await screen.findByText(/Failed to fetch/); 135 | expect(onSuccess).toHaveBeenCalledTimes(0); 136 | }); 137 | -------------------------------------------------------------------------------- /ui/src/NewPassword.tsx: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2023 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | import { PasswordElement } from "react-hook-form-mui"; 6 | import { useWatch } from "react-hook-form"; 7 | 8 | // Minimum password length, taken from [NIST 9 | // guidelines](https://pages.nist.gov/800-63-3/sp800-63b.html), section 5.1.1. 10 | // This is enforced on the frontend for now; a user who really wants to violate 11 | // the rule can via API request. 12 | const MIN_PASSWORD_LENGTH = 8; 13 | 14 | /// Form elements for setting a new password, shared between the ChangePassword 15 | /// dialog (for any user to change their own password) and AddEditDialog 16 | /// (for admins to add/edit any user). 17 | /// 18 | /// Does no validation if `!required`; AddEditDialog doesn't care about these 19 | /// fields unless the password action is "set" (rather than "leave" or "clear"). 20 | export default function NewPassword(props: { required?: boolean }) { 21 | const required = props.required ?? true; 22 | const newPasswordValue = useWatch({ name: "newPassword" }); 23 | return ( 24 | <> 25 | { 33 | if (!required) { 34 | return true; 35 | } else if (v.length === 0) { 36 | return "New password is required."; 37 | } else if (v.length < MIN_PASSWORD_LENGTH) { 38 | return `Passwords must have at least ${MIN_PASSWORD_LENGTH} characters.`; 39 | } else { 40 | return true; 41 | } 42 | }, 43 | }} 44 | fullWidth 45 | helperText=" " 46 | /> 47 | { 58 | if (!required) { 59 | return true; 60 | } else if (v.length === 0) { 61 | return "Must confirm new password."; 62 | } else if (v !== newPasswordValue) { 63 | return "Passwords must match."; 64 | } else { 65 | return true; 66 | } 67 | }, 68 | }} 69 | /> 70 | 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /ui/src/Users/DeleteDialog.tsx: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2023 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | import { LoadingButton } from "@mui/lab"; 6 | import Button from "@mui/material/Button"; 7 | import Dialog from "@mui/material/Dialog"; 8 | import DialogActions from "@mui/material/DialogActions"; 9 | import DialogContent from "@mui/material/DialogContent"; 10 | import DialogTitle from "@mui/material/DialogTitle"; 11 | import { useEffect, useState } from "react"; 12 | import * as api from "../api"; 13 | import { useSnackbars } from "../snackbars"; 14 | 15 | interface Props { 16 | userToDelete?: api.UserWithId; 17 | csrf?: string; 18 | onClose: () => void; 19 | refetch: () => void; 20 | } 21 | 22 | export default function DeleteDialog({ 23 | userToDelete, 24 | csrf, 25 | onClose, 26 | refetch, 27 | }: Props): JSX.Element { 28 | const [req, setReq] = useState(); 29 | const snackbars = useSnackbars(); 30 | useEffect(() => { 31 | const abort = new AbortController(); 32 | const doFetch = async (id: number, signal: AbortSignal) => { 33 | const resp = await api.deleteUser( 34 | id, 35 | { 36 | csrf: csrf, 37 | }, 38 | { signal } 39 | ); 40 | setReq(undefined); 41 | switch (resp.status) { 42 | case "aborted": 43 | break; 44 | case "error": 45 | snackbars.enqueue({ 46 | message: "Delete failed: " + resp.message, 47 | }); 48 | break; 49 | case "success": 50 | refetch(); 51 | onClose(); 52 | break; 53 | } 54 | }; 55 | if (req !== undefined) { 56 | doFetch(req, abort.signal); 57 | } 58 | return () => { 59 | abort.abort(); 60 | }; 61 | }, [req, csrf, snackbars, onClose, refetch]); 62 | return ( 63 | 64 | Delete user {userToDelete?.user.username} 65 | 66 | This will permanently delete the given user and all associated sessions. 67 | There's no undo! 68 | 69 | 70 | 73 | setReq(userToDelete?.id)} 76 | color="secondary" 77 | variant="contained" 78 | > 79 | Delete 80 | 81 | 82 | 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /ui/src/Users/index.tsx: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2022 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | import Alert from "@mui/material/Alert"; 6 | import Paper from "@mui/material/Paper"; 7 | import Menu from "@mui/material/Menu"; 8 | import MenuItem from "@mui/material/MenuItem"; 9 | import Skeleton from "@mui/material/Skeleton"; 10 | import Table from "@mui/material/Table"; 11 | import TableBody from "@mui/material/TableBody"; 12 | import TableCell from "@mui/material/TableCell"; 13 | import TableContainer from "@mui/material/TableContainer"; 14 | import TableHead from "@mui/material/TableHead"; 15 | import TableRow, { TableRowProps } from "@mui/material/TableRow"; 16 | import Typography from "@mui/material/Typography"; 17 | import { useEffect, useState } from "react"; 18 | import * as api from "../api"; 19 | import { FrameProps } from "../App"; 20 | import AddIcon from "@mui/icons-material/Add"; 21 | import MoreVertIcon from "@mui/icons-material/MoreVert"; 22 | import IconButton from "@mui/material/IconButton"; 23 | import DeleteDialog from "./DeleteDialog"; 24 | import AddEditDialog from "./AddEditDialog"; 25 | import React from "react"; 26 | 27 | interface Props { 28 | Frame: (props: FrameProps) => JSX.Element; 29 | csrf?: string; 30 | } 31 | 32 | interface RowProps extends TableRowProps { 33 | userId: React.ReactNode; 34 | userName: React.ReactNode; 35 | gutter?: React.ReactNode; 36 | } 37 | 38 | /// More menu attached to a particular user row. 39 | interface More { 40 | user: api.UserWithId; 41 | anchor: HTMLElement; 42 | } 43 | 44 | const Row = ({ userId, userName, gutter, ...rest }: RowProps) => ( 45 | 46 | {userId} 47 | {userName} 48 | {gutter} 49 | 50 | ); 51 | 52 | const Main = ({ Frame, csrf }: Props) => { 53 | const [users, setUsers] = useState< 54 | api.FetchResult | undefined 55 | >(); 56 | const [more, setMore] = useState(); 57 | const [fetchSeq, setFetchSeq] = useState(0); 58 | const [userToEdit, setUserToEdit] = useState< 59 | undefined | null | api.UserWithId 60 | >(); 61 | const [deleteUser, setDeleteUser] = useState(); 62 | const refetch = () => setFetchSeq((s) => s + 1); 63 | useEffect(() => { 64 | const abort = new AbortController(); 65 | const doFetch = async (signal: AbortSignal) => { 66 | setUsers(await api.users({ signal })); 67 | }; 68 | doFetch(abort.signal); 69 | return () => { 70 | abort.abort(); 71 | }; 72 | }, [fetchSeq]); 73 | 74 | return ( 75 | 76 | 77 | 78 | 79 | setUserToEdit(null)} 86 | > 87 | 88 | 89 | } 90 | /> 91 | 92 | 93 | {users === undefined && ( 94 | } 97 | userName={} 98 | /> 99 | )} 100 | {users?.status === "error" && ( 101 | 102 | 103 | {users.message} 104 | 105 | 106 | )} 107 | {users?.status === "success" && 108 | users.response.users.map((u) => ( 109 | 117 | setMore({ 118 | user: u, 119 | anchor: e.currentTarget, 120 | }) 121 | } 122 | > 123 | 124 | 125 | } 126 | /> 127 | ))} 128 | 129 |
130 |
131 | setMore(undefined)} 135 | > 136 | { 138 | setUserToEdit(more?.user); 139 | setMore(undefined); 140 | }} 141 | > 142 | Edit 143 | 144 | 145 | { 148 | setDeleteUser(more?.user); 149 | setMore(undefined); 150 | }} 151 | > 152 | Delete 153 | 154 | 155 | 156 | {userToEdit !== undefined && ( 157 | setUserToEdit(undefined)} 161 | csrf={csrf} 162 | /> 163 | )} 164 | setDeleteUser(undefined)} 168 | csrf={csrf} 169 | /> 170 | 171 | ); 172 | }; 173 | 174 | export default Main; 175 | -------------------------------------------------------------------------------- /ui/src/aspect.ts: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | /** 6 | * Sets CSS properties on innerRef to fill as much of rect 7 | * as possible while maintaining aspect ratio. 8 | * 9 | * While Chrome 89 supports the "aspect-ratio" CSS property and behaves in a 10 | * predictable way, Firefox 87 doesn't. Emulating it with an child 11 | * doesn't work well either for using a (flex item) ancestor's (calculated) 12 | * height to compute the 's width and then the parent's width. There are 13 | * open bugs that look related, eg: 14 | * https://bugzilla.mozilla.org/show_bug.cgi?id=1349738 15 | * https://bugzilla.mozilla.org/show_bug.cgi?id=1690423 16 | * so just do it all by hand. The caller should use a ResizeObserver. 17 | */ 18 | export function fillAspect( 19 | rect: DOMRectReadOnly, 20 | innerRef: React.RefObject, 21 | aspect: [number, number] 22 | ) { 23 | const w = rect.width; 24 | const h = rect.height; 25 | const hFromW = (w * aspect[1]) / aspect[0]; 26 | const inner = innerRef.current; 27 | if (inner === null) { 28 | return; 29 | } 30 | if (hFromW > h) { 31 | inner.style.width = `${(h * aspect[0]) / aspect[1]}px`; 32 | inner.style.height = `${h}px`; 33 | } else { 34 | inner.style.width = `${w}px`; 35 | inner.style.height = `${hFromW}px`; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /ui/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | import AppBar from "@mui/material/AppBar"; 6 | import Drawer from "@mui/material/Drawer"; 7 | import List from "@mui/material/List"; 8 | import ListItemButton from "@mui/material/ListItemButton"; 9 | import ListItemIcon from "@mui/material/ListItemIcon"; 10 | import ListItemText from "@mui/material/ListItemText"; 11 | import ListIcon from "@mui/icons-material/List"; 12 | import PeopleIcon from "@mui/icons-material/People"; 13 | import Videocam from "@mui/icons-material/Videocam"; 14 | import * as api from "../api"; 15 | 16 | import MoonfireMenu from "../AppMenu"; 17 | import { useReducer } from "react"; 18 | import { LoginState } from "../App"; 19 | import { Link } from "react-router-dom"; 20 | 21 | export default function Header({ 22 | loginState, 23 | logout, 24 | setChangePasswordOpen, 25 | activityMenuPart, 26 | setLoginState, 27 | toplevel, 28 | }: { 29 | loginState: LoginState; 30 | logout: () => void; 31 | setChangePasswordOpen: React.Dispatch>; 32 | activityMenuPart?: JSX.Element; 33 | setLoginState: React.Dispatch>; 34 | toplevel: api.ToplevelResponse | null; 35 | }) { 36 | const [showMenu, toggleShowMenu] = useReducer((m: boolean) => !m, false); 37 | 38 | return ( 39 | <> 40 | 41 | { 44 | setLoginState("user-requested-login"); 45 | }} 46 | logout={logout} 47 | changePassword={() => setChangePasswordOpen(true)} 48 | menuClick={toggleShowMenu} 49 | activityMenuPart={activityMenuPart} 50 | /> 51 | 52 | 60 | 61 | 67 | 68 | 69 | 70 | 71 | 72 | 78 | 79 | 80 | 81 | 82 | 83 | {toplevel?.permissions.adminUsers && ( 84 | 90 | 91 | 92 | 93 | 94 | 95 | )} 96 | 97 | 98 | 99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /ui/src/components/ThemeMode.tsx: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | /* eslint-disable no-unused-vars */ 6 | import { useColorScheme } from "@mui/material/styles"; 7 | import React, { createContext } from "react"; 8 | 9 | export enum CurrentMode { 10 | Auto = 0, 11 | Light = 1, 12 | Dark = 2, 13 | } 14 | 15 | interface ThemeProps { 16 | changeTheme: () => void; 17 | currentTheme: "dark" | "light"; 18 | choosenTheme: CurrentMode; 19 | } 20 | 21 | export const ThemeContext = createContext({ 22 | currentTheme: window.matchMedia("(prefers-color-scheme: dark)").matches 23 | ? "dark" 24 | : "light", 25 | changeTheme: () => {}, 26 | choosenTheme: CurrentMode.Auto, 27 | }); 28 | 29 | const ThemeMode = ({ children }: { children: JSX.Element }): JSX.Element => { 30 | const { mode, setMode } = useColorScheme(); 31 | 32 | const useMediaQuery = (query: string) => { 33 | const [matches, setMatches] = React.useState( 34 | () => window.matchMedia(query).matches 35 | ); 36 | React.useEffect(() => { 37 | const m = window.matchMedia(query); 38 | const l = () => setMatches(m.matches); 39 | m.addEventListener("change", l); 40 | return () => m.removeEventListener("change", l); 41 | }, [query]); 42 | return matches; 43 | }; 44 | 45 | const detectedSystemColorScheme = useMediaQuery( 46 | "(prefers-color-scheme: dark)" 47 | ) 48 | ? "dark" 49 | : "light"; 50 | 51 | const changeTheme = React.useCallback(() => { 52 | setMode(mode === "dark" ? "light" : mode === "light" ? "system" : "dark"); 53 | }, [mode, setMode]); 54 | 55 | const currentTheme = 56 | mode === "system" 57 | ? detectedSystemColorScheme 58 | : mode ?? detectedSystemColorScheme; 59 | const choosenTheme = 60 | mode === "dark" 61 | ? CurrentMode.Dark 62 | : mode === "light" 63 | ? CurrentMode.Light 64 | : CurrentMode.Auto; 65 | 66 | return ( 67 | 68 | {children} 69 | 70 | ); 71 | }; 72 | 73 | export default ThemeMode; 74 | 75 | export const useThemeMode = () => React.useContext(ThemeContext); 76 | -------------------------------------------------------------------------------- /ui/src/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Moonfire NVR, a security camera network video recorder. 3 | * Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 4 | * SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 5 | */ 6 | 7 | :root { 8 | --mui-palette-AppBar-darkBg: #000 !important; 9 | --mui-palette-primary-main: #000 !important; 10 | --mui-palette-secondary-main: #e65100 !important; 11 | } 12 | 13 | html, 14 | body, 15 | #root { 16 | width: 100%; 17 | height: 100%; 18 | } 19 | 20 | a { 21 | color: inherit; 22 | } 23 | 24 | [data-mui-color-scheme="dark"] { 25 | color-scheme: dark; 26 | } 27 | -------------------------------------------------------------------------------- /ui/src/index.tsx: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | import CssBaseline from "@mui/material/CssBaseline"; 6 | import { 7 | Experimental_CssVarsProvider, 8 | experimental_extendTheme, 9 | } from "@mui/material/styles"; 10 | import StyledEngineProvider from "@mui/material/StyledEngineProvider"; 11 | import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; 12 | import "@fontsource/roboto"; 13 | import React from "react"; 14 | import { createRoot } from "react-dom/client"; 15 | import App from "./App"; 16 | import ErrorBoundary from "./ErrorBoundary"; 17 | import { SnackbarProvider } from "./snackbars"; 18 | import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; 19 | import "./index.css"; 20 | import { HashRouter } from "react-router-dom"; 21 | import ThemeMode from "./components/ThemeMode"; 22 | 23 | const themeExtended = experimental_extendTheme({ 24 | colorSchemes: { 25 | dark: { 26 | palette: { 27 | primary: { 28 | main: "#000000", 29 | }, 30 | secondary: { 31 | main: "#e65100", 32 | }, 33 | }, 34 | }, 35 | }, 36 | }); 37 | const container = document.getElementById("root"); 38 | const root = createRoot(container!); 39 | root.render( 40 | 41 | 42 | {/* */} 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | {/* */} 58 | 59 | 60 | ); 61 | -------------------------------------------------------------------------------- /ui/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 21 | 23 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /ui/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | /// 6 | -------------------------------------------------------------------------------- /ui/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 6 | // allows you to do things like: 7 | // expect(element).toHaveTextContent(/react/i) 8 | // learn more: https://github.com/testing-library/jest-dom 9 | import "@testing-library/jest-dom"; 10 | import { vi } from "vitest"; 11 | 12 | Object.defineProperty(window, "matchMedia", { 13 | writable: true, 14 | value: vi.fn().mockImplementation((query) => ({ 15 | matches: false, 16 | media: query, 17 | onchange: null, 18 | addListener: vi.fn(), // deprecated 19 | removeListener: vi.fn(), // deprecated 20 | addEventListener: vi.fn(), 21 | removeEventListener: vi.fn(), 22 | dispatchEvent: vi.fn(), 23 | })), 24 | }); 25 | -------------------------------------------------------------------------------- /ui/src/snackbars.test.tsx: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | import { act, render, screen, waitFor } from "@testing-library/react"; 6 | import { useEffect } from "react"; 7 | import { SnackbarProvider, useSnackbars } from "./snackbars"; 8 | import { beforeEach, afterEach, expect, test, vi } from "vitest"; 9 | 10 | // Mock out timers. 11 | beforeEach(() => { 12 | vi.useFakeTimers(); 13 | }); 14 | afterEach(() => { 15 | vi.runOnlyPendingTimers(); 16 | vi.useRealTimers(); 17 | }); 18 | 19 | test("notifications that time out", async () => { 20 | function AddSnackbar() { 21 | const snackbars = useSnackbars(); 22 | useEffect(() => { 23 | snackbars.enqueue({ message: "message A" }); 24 | snackbars.enqueue({ message: "message B" }); 25 | }); 26 | return null; 27 | } 28 | 29 | render( 30 | 31 | 32 | 33 | ); 34 | 35 | // message A should be present immediately. 36 | expect(screen.getByText(/message A/)).toBeInTheDocument(); 37 | expect(screen.queryByText(/message B/)).not.toBeInTheDocument(); 38 | 39 | // ...then start to close... 40 | act(() => vi.advanceTimersByTime(5000)); 41 | expect(screen.getByText(/message A/)).toBeInTheDocument(); 42 | expect(screen.queryByText(/message B/)).not.toBeInTheDocument(); 43 | 44 | // ...then it should close and message B should open... 45 | act(() => vi.runOnlyPendingTimers()); 46 | await waitFor(() => 47 | expect(screen.queryByText(/message A/)).not.toBeInTheDocument() 48 | ); 49 | expect(screen.getByText(/message B/)).toBeInTheDocument(); 50 | 51 | // ...then message B should start to close... 52 | act(() => vi.advanceTimersByTime(5000)); 53 | expect(screen.queryByText(/message A/)).not.toBeInTheDocument(); 54 | expect(screen.getByText(/message B/)).toBeInTheDocument(); 55 | 56 | // ...then message B should fully close. 57 | act(() => vi.runOnlyPendingTimers()); 58 | expect(screen.queryByText(/message A/)).not.toBeInTheDocument(); 59 | await waitFor(() => 60 | expect(screen.queryByText(/message B/)).not.toBeInTheDocument() 61 | ); 62 | }); 63 | 64 | // TODO: test dismiss. 65 | // TODO: test that context never changes. 66 | // TODO: test drop-on-enqueue. 67 | // TODO: test drop-after-enqueue, with manual and automatic keys. 68 | -------------------------------------------------------------------------------- /ui/src/snackbars.tsx: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | /** 6 | * App-wide provider for imperative snackbar. 7 | * 8 | * I chose not to use the popular 9 | * notistack because it 10 | * doesn't seem oriented for complying with the material.io spec. 12 | * Besides supporting non-compliant behaviors (eg maxSnack > 1), 13 | * it doesn't actually enqueue notifications. Newer ones replace older ones. 14 | * 15 | * This isn't as flexible as notistack because I don't need that 16 | * flexibility (yet). 17 | */ 18 | 19 | import IconButton from "@mui/material/IconButton"; 20 | import Snackbar, { 21 | SnackbarCloseReason, 22 | SnackbarProps, 23 | } from "@mui/material/Snackbar"; 24 | import CloseIcon from "@mui/icons-material/Close"; 25 | import React, { useContext } from "react"; 26 | 27 | interface SnackbarProviderProps { 28 | /** 29 | * The autohide duration to use if none is provided to enqueue. 30 | */ 31 | autoHideDuration: number; 32 | 33 | children: React.ReactNode; 34 | } 35 | 36 | export interface MySnackbarProps 37 | extends Omit< 38 | SnackbarProps, 39 | | "key" 40 | | "anchorOrigin" 41 | | "open" 42 | | "handleClosed" 43 | | "TransitionProps" 44 | | "actions" 45 | > { 46 | key?: React.Key; 47 | } 48 | 49 | type MySnackbarPropsWithRequiredKey = Omit & 50 | Required>; 51 | interface Enqueued extends MySnackbarPropsWithRequiredKey { 52 | open: boolean; 53 | } 54 | 55 | /** 56 | * Imperative interface to enqueue and close app-wide snackbars. 57 | * These methods should be called from effects (not directly from render). 58 | */ 59 | export interface Snackbars { 60 | /** 61 | * Enqueues a snackbar. 62 | * 63 | * @param snackbar 64 | * The snackbar to add. The only required property is message. If 65 | * key is present, it will close any message with the same key 66 | * immediately, as well as be returned so it can be passed to close again 67 | * later. Note that currently several properties are used internally and 68 | * can't be specified, including actions. 69 | * @return A key that can be passed to close: the caller-specified key if 70 | * possible, or an internally generated key otherwise. 71 | */ 72 | enqueue: (snackbar: MySnackbarProps) => React.Key; 73 | 74 | /** 75 | * Closes a snackbar if present. 76 | * 77 | * If it is currently visible, it will be allowed to gracefully close. 78 | * Otherwise it's removed from the queue. 79 | */ 80 | close: (key: React.Key) => void; 81 | } 82 | 83 | interface State { 84 | queue: Enqueued[]; 85 | } 86 | 87 | const ctx = React.createContext(null); 88 | 89 | /** 90 | * Provides a Snackbars instance for use by useSnackbars. 91 | */ 92 | // This is a class because I want to guarantee the context value never changes, 93 | // and I couldn't figure out a way to do that with hooks. 94 | export class SnackbarProvider 95 | extends React.Component 96 | implements Snackbars 97 | { 98 | constructor(props: SnackbarProviderProps) { 99 | super(props); 100 | this.state = { queue: [] }; 101 | } 102 | 103 | autoKeySeq = 0; 104 | 105 | enqueue(snackbar: MySnackbarProps): React.Key { 106 | let key = 107 | snackbar.key === undefined ? `auto-${this.autoKeySeq++}` : snackbar.key; 108 | // TODO: filter existing. 109 | this.setState((state) => ({ 110 | queue: [...state.queue, { key, open: true, ...snackbar }], 111 | })); 112 | return key; 113 | } 114 | 115 | handleCloseSnackbar = ( 116 | key: React.Key, 117 | event: Event | React.SyntheticEvent, 118 | reason: SnackbarCloseReason 119 | ) => { 120 | if (reason === "clickaway") return; 121 | this.setState((state) => { 122 | const snack = state.queue[0]; 123 | if (snack?.key !== key) { 124 | console.warn(`Active snack is ${snack?.key}; expected ${key}`); 125 | return null; // no change. 126 | } 127 | const newSnack: Enqueued = { ...snack, open: false }; 128 | return { queue: [newSnack, ...state.queue.slice(1)] }; 129 | }); 130 | }; 131 | 132 | handleSnackbarExited = (key: React.Key) => { 133 | this.setState((state) => ({ queue: state.queue.slice(1) })); 134 | }; 135 | 136 | close(key: React.Key): void { 137 | this.setState((state) => { 138 | // If this is the active snackbar, let it close gracefully, as in 139 | // handleCloseSnackbar. 140 | if (state.queue[0]?.key === key) { 141 | const newSnack: Enqueued = { ...state.queue[0], open: false }; 142 | return { queue: [newSnack, ...state.queue.slice(1)] }; 143 | } 144 | // Otherwise, remove it before it shows up at all. 145 | return { queue: state.queue.filter((e: Enqueued) => e.key !== key) }; 146 | }); 147 | } 148 | 149 | render(): JSX.Element { 150 | const first = this.state.queue[0]; 151 | const snackbars: Snackbars = this; 152 | return ( 153 | 154 | {this.props.children} 155 | {first === undefined ? null : ( 156 | 166 | this.handleCloseSnackbar(first.key, event, reason) 167 | } 168 | TransitionProps={{ 169 | onExited: () => this.handleSnackbarExited(first.key), 170 | }} 171 | action={ 172 | this.close(first.key)} 177 | > 178 | 179 | 180 | } 181 | /> 182 | )} 183 | 184 | ); 185 | } 186 | } 187 | 188 | /** Returns a Snackbars from context. */ 189 | export function useSnackbars(): Snackbars { 190 | return useContext(ctx)!; 191 | } 192 | -------------------------------------------------------------------------------- /ui/src/testutil.tsx: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | import { createTheme, ThemeProvider } from "@mui/material/styles"; 6 | import { render } from "@testing-library/react"; 7 | import { MemoryRouter } from "react-router-dom"; 8 | import { SnackbarProvider } from "./snackbars"; 9 | import React from "react"; 10 | 11 | export function renderWithCtx( 12 | children: React.ReactElement 13 | ): Pick, "rerender"> { 14 | function wrapped(children: React.ReactElement): React.ReactElement { 15 | return ( 16 | 17 | 18 | {children} 19 | 20 | 21 | ); 22 | } 23 | const { rerender } = render(wrapped(children)); 24 | return { 25 | rerender: (children: React.ReactElement) => rerender(wrapped(children)), 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /ui/src/types.ts: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | /** 6 | * @file Types from the Moonfire NVR API. 7 | * See descriptions in ref/api.md. 8 | */ 9 | 10 | export type StreamType = "main" | "sub"; 11 | 12 | export interface Session { 13 | csrf: string; 14 | } 15 | 16 | export interface Camera { 17 | uuid: string; 18 | shortName: string; 19 | description: string; 20 | streams: Partial>; 21 | } 22 | 23 | export interface Stream { 24 | camera: Camera; // back-reference added within api.ts. 25 | id: number; 26 | streamType: StreamType; // likewise. 27 | retainBytes: number; 28 | minStartTime90k: number; 29 | maxEndTime90k: number; 30 | totalDuration90k: number; 31 | totalSampleFileBytes: number; 32 | fsBytes: number; 33 | days: Record; 34 | record: boolean; 35 | } 36 | 37 | export interface Day { 38 | totalDuration90k: number; 39 | startTime90k: number; 40 | endTime90k: number; 41 | } 42 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noFallthroughCasesInSwitch": true 21 | }, 22 | "include": ["src"], 23 | "references": [{ "path": "./tsconfig.node.json" }] 24 | } 25 | -------------------------------------------------------------------------------- /ui/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /ui/vite.config.ts: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2023 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | import { defineConfig } from "vite"; 6 | import react from "@vitejs/plugin-react-swc"; 7 | import viteCompression from "vite-plugin-compression"; 8 | import viteLegacyPlugin from "@vitejs/plugin-legacy"; 9 | 10 | const target = process.env.PROXY_TARGET ?? "http://localhost:8080/"; 11 | 12 | // https://vitejs.dev/config/ 13 | export default defineConfig({ 14 | plugins: [ 15 | react(), 16 | viteCompression(), 17 | viteLegacyPlugin({ 18 | targets: ["defaults", "fully supports es6-module"], 19 | }), 20 | ], 21 | server: { 22 | proxy: { 23 | "/api": { 24 | target, 25 | 26 | // Moonfire NVR needs WebSocket connections for live connections (and 27 | // likely more in the future: 28 | // .) 29 | ws: true, 30 | changeOrigin: true, 31 | 32 | // If the backing host is https, Moonfire NVR will set a `secure` 33 | // attribute on cookie responses, so that the browser will only send 34 | // them over https connections. This is a good security practice, but 35 | // it means a non-https development proxy server won't work. Strip out 36 | // this attribute in the proxy with code from here: 37 | // https://github.com/chimurai/http-proxy-middleware/issues/169#issuecomment-575027907 38 | // See also discussion in guide/developing-ui.md. 39 | configure: (proxy, options) => { 40 | // The `changeOrigin` above doesn't appear to apply to websocket 41 | // requests. This has a similar effect. 42 | proxy.on("proxyReqWs", (proxyReq, req, socket, options, head) => { 43 | proxyReq.setHeader("origin", target); 44 | }); 45 | 46 | proxy.on("proxyRes", (proxyRes, req, res) => { 47 | const sc = proxyRes.headers["set-cookie"]; 48 | if (Array.isArray(sc)) { 49 | proxyRes.headers["set-cookie"] = sc.map((sc) => { 50 | return sc 51 | .split(";") 52 | .filter((v) => v.trim().toLowerCase() !== "secure") 53 | .join("; "); 54 | }); 55 | } 56 | }); 57 | }, 58 | }, 59 | }, 60 | }, 61 | }); 62 | -------------------------------------------------------------------------------- /ui/vitest.config.ts: -------------------------------------------------------------------------------- 1 | // This file is part of Moonfire NVR, a security camera network video recorder. 2 | // Copyright (C) 2023 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. 3 | // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception 4 | 5 | import { defineConfig } from "vitest/config"; 6 | 7 | export default defineConfig({ 8 | test: { 9 | environment: "jsdom", 10 | globals: true, 11 | setupFiles: ["./src/setupTests.ts"], 12 | 13 | // This avoids node's native fetch from causing vitest workers to hang 14 | // and use 100% CPU. 15 | // 16 | pool: "forks", 17 | }, 18 | }); 19 | --------------------------------------------------------------------------------